From 230ec2ae05c29887f4be9cdd26acc8985f2566f9 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 16:36:41 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Fiche=20:=20=C2=AB=20Modifier=20=C2=BB=20d'?= =?UTF-8?q?un=20clic=20pour=20l'administrateur=20;=20=C2=AB=20=C3=A0=20v?= =?UTF-8?q?=C3=A9rifier=20=C2=BB=20entier=20sur=20un=20cadre=20rond=20;=20?= =?UTF-8?q?arbre=20jusqu'en=20bas=20de=20l'=C3=A9cran=20sur=20iPad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.css | 2 ++ frontend/src/App.jsx | 1 + frontend/src/components/Galaxy.css | 4 +++ frontend/src/components/Galaxy.jsx | 27 +++++++++++++-------- frontend/src/components/PersonCard.css | 8 ++++++ frontend/src/components/PersonCard.jsx | 10 +++++++- frontend/src/components/galaxy/renderers.js | 4 +++ frontend/src/i18n/en/fiche.js | 1 + 8 files changed, 46 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 5ce57fb..798f612 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,6 +1,8 @@ .app { width: 100vw; height: 100vh; + /* La hauteur visible réelle (barres de Safari comprises) là où le navigateur la connaît */ + height: 100dvh; position: relative; overflow: hidden; background: #FBFAF7; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 869221b..d0524cc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3259,6 +3259,7 @@ function App() { onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)} onUploadMomentFile={tree.canEditCurrentTree || tree.canSubmitContribution ? media.uploadMomentFile : undefined} onMomentSaved={media.refreshMomentMedia} + onQuickEdit={tree.canEditCurrentTree && userRole.isAdmin && !isDemoMode ? handleEditToggle : undefined} /> )} {/* Crayon de modification masqué en démo : rien n'y est enregistré */} diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/Galaxy.css index e08c47c..ba1f5f9 100644 --- a/frontend/src/components/Galaxy.css +++ b/frontend/src/components/Galaxy.css @@ -1,4 +1,8 @@ .galaxy-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; cursor: default; display: block; touch-action: none; diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index c85398b..3352f90 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -447,24 +447,31 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig } }, [isZooming, targetTransformRef, zoomTimeoutRef]) - // Resize canvas to device pixel ratio + // Resize canvas to device pixel ratio. Le CSS donne au canvas toute la page (.galaxy-canvas) ; on lit sa taille + // réelle. Avec window.innerHeight, Safari sur iPad gardait une hauteur prise clavier ou barre ouverte, sans + // événement resize ensuite : l'arbre s'arrêtait net au-dessus du bas de l'écran. useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return undefined const resize = () => { - const canvas = canvasRef.current - if (!canvas) return - const cssW = window.innerWidth - const cssH = window.innerHeight + const cssW = canvas.clientWidth || window.innerWidth + const cssH = canvas.clientHeight || window.innerHeight const dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = Math.floor(cssW * dpr), h = Math.floor(cssH * dpr) + if (canvas.width === w && canvas.height === h && dprRef.current === dpr) return dprRef.current = dpr - canvas.width = Math.floor(cssW * dpr) - canvas.height = Math.floor(cssH * dpr) - canvas.style.width = `${cssW}px` - canvas.style.height = `${cssH}px` + canvas.width = w + canvas.height = h needsRedrawRef.current = true } resize() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize) + observer?.observe(canvas) window.addEventListener('resize', resize) - return () => window.removeEventListener('resize', resize) + return () => { + observer?.disconnect() + window.removeEventListener('resize', resize) + } }, []) // Layout diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index c0ea473..20e6a81 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -296,3 +296,11 @@ /* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */ .pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); } + +/* « Modifier » de l'administrateur : une pastille de la ligne des repères, qui fait passer la fiche en modification */ +.pc-quick-edit { + display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 12px; border: 0; border-radius: 999px; cursor: pointer; + background: var(--pz-surface); color: var(--pz-ink); box-shadow: inset 0 0 0 1px var(--pz-line); font: 500 12.5px var(--pz-sans); +} +.pc-quick-edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } +@media (hover: hover) and (pointer: fine) { .pc-quick-edit:hover { background: var(--pz-line-soft); } } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 210b863..3391a98 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, forwardRef } from 'react' import { createPortal } from 'react-dom' -import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react' +import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight, Pencil } from 'lucide-react' import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData' import { fetchPersonLife } from '../api/treeApi.js' import LifeTimeline from './LifeTimeline.jsx' @@ -74,6 +74,8 @@ const PersonCard = ({ onUploadMomentFile, onMomentSaved, saveOnLeaveRef = null, + // Administrateur, hors démo : la fiche passe en modification d'un clic, sans chercher le crayon de l'arbre + onQuickEdit, }) => { // Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || getPersonTaggedMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos')) @@ -754,6 +756,12 @@ const PersonCard = ({ )} {!editMode && person.toVerify && {t('À vérifier')}} {!editMode && person.profession && {person.profession}} + {!editMode && onQuickEdit && ( + + )} {editMode && ((unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim())) && ( diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 6097104..381f9a8 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -602,7 +602,11 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { ctx.shadowOffsetY = shadowY } + // Le cadre découpe le canvas à sa forme : save/restore autour, sinon la marque ci-dessous est rognée + // (sur un cadre rond, elle tombe presque entière hors du cercle) + ctx.save() drawFrame(ctx, frameType, person, img) + ctx.restore() // « À vérifier » : petit rond terracotta avec un « ? » en haut à droite du cadre, absent au dézoom if (person.toVerify && withDetail) drawToVerifyMark(ctx) ctx.restore() diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 03072e5..09a970f 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -1,6 +1,7 @@ // Traductions anglaises, clé = texte français exact passé à t(). export default { // Fiche d'une personne (PersonCard) + 'Modifier': 'Edit', 'Photo': 'Photo', 'Vidéo': 'Video', 'Audio': 'Audio', From befe8fe19ad7e47831eebd3684c9e7687b9ee1da Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 16:57:08 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=C2=AB=20=C3=80=20v=C3=A9rifier=20=C2=BB=20?= =?UTF-8?q?:=20drawPortrait,=20et=20un=20test=20qui=20=C3=A9choue=20si=20l?= =?UTF-8?q?a=20marque=20repasse=20sous=20la=20d=C3=A9coupe=20du=20cadre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/galaxy/renderers.js | 18 ++++++++------ .../src/components/galaxy/renderers.test.js | 24 ++++++++++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 381f9a8..e9488b5 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -602,13 +602,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { ctx.shadowOffsetY = shadowY } - // Le cadre découpe le canvas à sa forme : save/restore autour, sinon la marque ci-dessous est rognée - // (sur un cadre rond, elle tombe presque entière hors du cercle) - ctx.save() - drawFrame(ctx, frameType, person, img) - ctx.restore() - // « À vérifier » : petit rond terracotta avec un « ? » en haut à droite du cadre, absent au dézoom - if (person.toVerify && withDetail) drawToVerifyMark(ctx) + drawPortrait(ctx, frameType, person, img, person.toVerify && withDetail) ctx.restore() // --- Labels (estompés puis absents en dézoom) --- @@ -935,6 +929,16 @@ function resolveFrameType(person, img) { return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop)) } +// Cadre et portrait, puis la marque « À vérifier » (petit rond terracotta avec un « ? » en haut à droite du cadre, +// absente au dézoom). Le cadre découpe le canvas à sa forme : save/restore autour, sinon la marque est rognée +// (sur un cadre rond, elle tombe presque entière hors du cercle) +export function drawPortrait(ctx, frameType, person, img, withMark) { + ctx.save() + drawFrame(ctx, frameType, person, img) + ctx.restore() + if (withMark) drawToVerifyMark(ctx) +} + function drawToVerifyMark(ctx) { const r = 9 const x = PERSON_R * 0.74, y = -PERSON_R * 0.74 diff --git a/frontend/src/components/galaxy/renderers.test.js b/frontend/src/components/galaxy/renderers.test.js index a600ef0..91da576 100644 --- a/frontend/src/components/galaxy/renderers.test.js +++ b/frontend/src/components/galaxy/renderers.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { traceSibship } from './renderers' +import { drawPortrait, traceSibship } from './renderers' // Contexte canvas minimal qui enregistre les appels de tracé const recorder = () => { @@ -49,3 +49,25 @@ describe('traceSibship (accolade franche)', () => { expect(curve.slice(5)).toEqual([515, 480]) }) }) + +describe('drawPortrait, marque « À vérifier »', () => { + const person = { firstName: 'Marie', lastName: 'Martin', isAlive: true } + for (const frame of ['round', 'square', 'polaroid', 'rect']) { + it(`cadre ${frame} : la marque est dessinée hors de la découpe du cadre`, () => { + const { ctx, calls } = recorder() + drawPortrait(ctx, frame, person, null, true) + const clip = calls.findIndex(c => c[0] === 'clip') + const restore = calls.findIndex((c, i) => i > clip && c[0] === 'restore') + const markText = calls.findIndex(c => c[0] === 'fillText' && c[1] === '?') + expect(clip).toBeGreaterThan(-1) + expect(restore).toBeGreaterThan(clip) + expect(markText).toBeGreaterThan(restore) + }) + } + + it('sans « À vérifier », pas de marque', () => { + const { ctx, calls } = recorder() + drawPortrait(ctx, 'round', person, null, false) + expect(calls.some(c => c[0] === 'fillText' && c[1] === '?')).toBe(false) + }) +})