From 754fd6268946955508f139e6786bbf90928ec612 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 21:23:31 +0200 Subject: [PATCH] =?UTF-8?q?Arbre=20:=20plus=20de=20=C2=AB=20+=20photo=20?= =?UTF-8?q?=C2=BB=20sur=20un=20portrait=20vide=20en=20modification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au survol, un portrait sans photo montrait « + photo » et un clic ouvrait le sélecteur de fichiers au lieu de la fiche. Le clic ouvre maintenant la fiche ; la photo s'ajoute par le badge appareil photo de la fiche. Retire le dessin, la zone de clic, l'entrée de fichier cachée et les demandes de recadrage et de source qui n'existaient que pour lui. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.jsx | 57 +------------------ frontend/src/components/Galaxy.jsx | 7 +-- frontend/src/components/PersonCard.jsx | 19 ------- frontend/src/components/galaxy/renderers.js | 39 +++---------- .../galaxy/useGalaxyInteractions.js | 15 +---- 5 files changed, 14 insertions(+), 123 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e6a1407..6c18939 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -51,7 +51,7 @@ import { reorderSiblings, uploadPersonMedia, } from './api/treeApi' -import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' +import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' import { fetchAuthOptions, oidcStartUrl } from './api/authApi' import { useAuth } from './hooks/useAuth' import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess' @@ -1715,51 +1715,6 @@ function App() { } }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) - // « + photo » dans l'arbre : le sélecteur part dans le clic (Safari iOS refuse un input file ouvert hors geste), - // puis la fenêtre de recadrage s'ouvre dans la fiche de la personne - const portraitInputRef = useRef(null) - const portraitPickerPersonIdRef = useRef(null) - const [pendingPortraitCrop, setPendingPortraitCrop] = useState(null) - const [photoSourceRequest, setPhotoSourceRequest] = useState(null) - const [immichSourceReady, setImmichSourceReady] = useState(false) - - // Administrateur : Immich proposé dans le menu des sources seulement s'il est configuré - useEffect(() => { - let cancelled = false - const treeId = tree.treeContext.treeId - const token = tree.treeContext.accessToken || auth.userAuth.token - if (!userRole.isAdmin || !tree.canEditCurrentTree || !treeId || !token) { - setImmichSourceReady(false) - return undefined - } - fetchImmichConfig(treeId, token) - .then(({ config }) => { if (!cancelled) setImmichSourceReady(Boolean(config?.configured)) }) - .catch(() => { if (!cancelled) setImmichSourceReady(false) }) - return () => { cancelled = true } - }, [userRole.isAdmin, tree.canEditCurrentTree, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token]) - - const handlePhotoPlaceholderClick = useCallback((personId) => { - // En modification avec Immich : le menu des sources s'ouvre dans la fiche (le badge en est l'ancre) - if (editMode.isActive && immichSourceReady) { - setPhotoSourceRequest({ personId }) - } else { - // Sans menu : le sélecteur de fichiers part tout de suite, dans le clic - portraitPickerPersonIdRef.current = personId - portraitInputRef.current?.click() - } - handlePersonSelect(personId) - }, [editMode.isActive, immichSourceReady, handlePersonSelect]) - - const handlePortraitPicked = useCallback(async (event) => { - const file = event.target.files?.[0] - event.target.value = '' - const personId = portraitPickerPersonIdRef.current - portraitPickerPersonIdRef.current = null - if (!file || personId === null) return - const result = await handlePersonAvatarUpload(personId, file) - if (result?.previewUrl) setPendingPortraitCrop({ personId, src: result.previewUrl }) - }, [handlePersonAvatarUpload]) - // Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant) const handleImmichChange = useCallback(async (personId) => { await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) @@ -3244,17 +3199,11 @@ function App() { zoomApiRef={galaxyZoomApiRef} focusPersonId={tree.treeContext.rootPersonId} assetRevision={tree.assetRevision} - // « + photo » seulement en modification : en lecture, un clic sur un portrait vide ouvre la fiche (issue #124) - photoHint={tree.canEditCurrentTree && editMode.isActive} - onPhotoPlaceholderClick={tree.canEditCurrentTree && editMode.isActive ? handlePhotoPlaceholderClick : null} reorderEnabled={canReorderSiblings} onReorderSiblings={handleReorderSiblings} familyTabs={familyTabs} /> - {tree.canEditCurrentTree && ( - - )} {tree.selectedPerson && ( setPendingPortraitCrop(null)} - photoSourceRequest={photoSourceRequest} - onPhotoSourceRequestDone={() => setPhotoSourceRequest(null)} treeId={tree.treeContext.treeId || null} immichToken={tree.treeContext.accessToken || auth.userAuth.token} onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined} diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 20e168c..d14f9a7 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -42,7 +42,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 } const centerX = (node) => node.x + node.width / 2 const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches -const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null, familyTabs = true }) => { +const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true }) => { const canvasRef = useRef(null) const layoutDataRef = useRef(null) // Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par @@ -78,8 +78,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Clic sur un badge : l'onglet de cette famille (ou, depuis un onglet, celui d'où l'on vient) const openFamily = useCallback((tabKey) => { if (tabKey != null) setActiveKey(String(tabKey)) }, []) // Propriétaire : un portrait vide survolé montre « + photo » - const photoHintRef = useRef(photoHint) - useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -169,7 +167,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily: openFamily, + getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily: openFamily, annotationHandlers, minScale: MIN_SCALE, maxScale: MAX_SCALE, @@ -907,7 +905,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig mediaHoverState: mediaHoverState.current, entrance, entranceActive, elapsed, view, - photoHint: photoHintRef.current, liftScales: liftScalesRef.current, }) drawRenvoiPills(ctx) diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 0d6e016..ee7b2e2 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -54,10 +54,6 @@ const PersonCard = ({ onAvatarUpload, onAvatarDelete, onAvatarCrop, - pendingPortraitCrop = null, - onPendingPortraitCropDone = null, - photoSourceRequest = null, - onPhotoSourceRequestDone = null, treeId = null, immichToken = null, onImmichChange, @@ -258,21 +254,6 @@ const PersonCard = ({ const chooseFileSource = () => { setShowSourceMenu(false); avatarInputRef.current?.click() } const chooseImmichSource = () => { setShowSourceMenu(false); setShowImmichPanel(true) } - // Clic sur « + photo » dans l'arbre : la photo vient d'être envoyée, le recadrage s'ouvre tout de suite - useEffect(() => { - if (!pendingPortraitCrop || String(pendingPortraitCrop.personId) !== String(person.id)) return - setCropState({ src: pendingPortraitCrop.src, mode: 'new', ownsUrl: true }) - onPendingPortraitCropDone?.() - }, [pendingPortraitCrop, person.id, onPendingPortraitCropDone]) - - // Clic sur « + photo » dans l'arbre, en modification avec Immich : même menu que le badge, sans clic de plus - useEffect(() => { - if (!photoSourceRequest || String(photoSourceRequest.personId) !== String(person.id)) return - if (immichConfigured === null) return - onPhotoSourceRequestDone?.() - if (immichUsable) openAvatarPicker() - }, [photoSourceRequest, person.id, immichConfigured, immichUsable, onPhotoSourceRequestDone]) // eslint-disable-line react-hooks/exhaustive-deps - // Focus search input when relation search opens useEffect(() => { if (activeRelationSearch) { diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 19ade18..a43583b 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -203,8 +203,6 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD export const renvoiHitAreas = [] // Badges « Famille X » sous les conjoints dont la famille est un onglet : { x0, y0, x1, y1, anchorId } export const familyHitAreas = [] -// Zones « + photo » : portraits vides, chez le propriétaire en modification (cercle du nœud, rempli au dessin) -export const photoPlaceholderHitAreas = [] const renvoiPills = [] const familyPills = [] // badges « Famille X » : { text, cx, cy, anchorId, tabKey, alpha } const RENVOI_FONT = '500 11px "DM Sans", system-ui, sans-serif' @@ -464,15 +462,11 @@ function measureTextCached(ctx, text, fontKey) { // ============================================================ function drawPersonNode(ctx, node, pos, nodeProgress, { nodeRandomData, imageCache, hoverScales, selectedGlowScales, searchGlowScales, - mediaHoverState, entrance, entranceActive, elapsed, view = null, photoHint = false, liftScales = null, + mediaHoverState, entrance, entranceActive, elapsed, view = null, liftScales = null, }) { const { cx, cy } = pos const person = getPersonById(node._data.id) || node._data const rd = nodeRandomData.get(node.id) - // Zone « + photo » : seulement là où le cadre invite à poser une photo (même condition que le dessin) - if (photoHint && !person.photo && nodeProgress >= 0.5) { - photoPlaceholderHitAreas.push({ personId: node._data.id, cx, cy, r: nodeRadius(node) }) - } const img = person.photo ? imageCache.get(person.photo) : null const frameType = resolveFrameType(person, img) const frameRot = rd?.frameRotation || 0 @@ -606,7 +600,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { ctx.shadowOffsetY = shadowY } - drawFrame(ctx, frameType, person, img, photoHint && !img ? hs : 0) + drawFrame(ctx, frameType, person, img) ctx.restore() // --- Labels (estompés puis absents en dézoom) --- @@ -932,7 +926,7 @@ function resolveFrameType(person, img) { return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop)) } -function drawFrame(ctx, frameType, person, img, hint = 0) { +function drawFrame(ctx, frameType, person, img) { if (frameType === 'polaroid') { const pw = PERSON_R + 4 const ph = PERSON_R + 4 @@ -950,7 +944,7 @@ function drawFrame(ctx, frameType, person, img, hint = 0) { ctx.beginPath() ctx.roundRect(-pw, -ph, pw * 2, ph * 2, 2) ctx.clip() - drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2, hint) + drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2) } else if (frameType === 'square') { const s = PERSON_R + 4 const pad = 3 @@ -966,7 +960,7 @@ function drawFrame(ctx, frameType, person, img, hint = 0) { ctx.beginPath() ctx.roundRect(-s, -s, s * 2, s * 2, 2) ctx.clip() - drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2, hint) + drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2) } else if (frameType === 'rect') { const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight @@ -988,7 +982,7 @@ function drawFrame(ctx, frameType, person, img, hint = 0) { ctx.beginPath() ctx.roundRect(-rw, -rh, rw * 2, rh * 2, 3) ctx.clip() - drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2, hint) + drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2) } else { ctx.fillStyle = '#FFFFFF' ctx.beginPath() @@ -1003,14 +997,14 @@ function drawFrame(ctx, frameType, person, img, hint = 0) { ctx.beginPath() ctx.arc(0, 0, PERSON_R, 0, Math.PI * 2) ctx.clip() - drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2, hint) + drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2) } } // ============================================================ // Dessiner l'image ou le placeholder initiales // ============================================================ -function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) { +function drawPersonImage(ctx, img, person, x, y, w, h) { if (img) { drawImageCover(ctx, img, x, y, w, h, person.avatarCrop) if (!person.isAlive) { @@ -1022,25 +1016,9 @@ function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) { ctx.fillRect(x, y, w, h) ctx.textAlign = 'center' ctx.textBaseline = 'middle' - // Au survol, chez le propriétaire en modification, les initiales cèdent la place à « + photo » - const baseAlpha = ctx.globalAlpha - if (hint > 0.01) { - ctx.fillStyle = `rgba(248, 228, 219, ${hint})` - ctx.fillRect(x, y, w, h) - } - ctx.globalAlpha = baseAlpha * (1 - hint) ctx.fillStyle = COLORS.text ctx.font = '400 16px "Newsreader", Georgia, serif' ctx.fillText((person.firstName[0] || '') + (person.lastName[0] || ''), 0, 0) - if (hint > 0.01) { - ctx.globalAlpha = baseAlpha * hint - ctx.fillStyle = '#93402A' - ctx.font = '300 22px "DM Sans", system-ui, sans-serif' - ctx.fillText('+', 0, -6) - ctx.font = '500 9.5px "DM Sans", system-ui, sans-serif' - ctx.fillText(t('photo'), 0, 10) - } - ctx.globalAlpha = baseAlpha } } @@ -1062,7 +1040,6 @@ function drawImageCover(ctx, img, x, y, w, h, crop = null) { // ============================================================ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, renderState) { const view = renderState.view || null - photoPlaceholderHitAreas.length = 0 layoutData.children.forEach(node => { const pos = posMap.get(node.id) if (!pos) return diff --git a/frontend/src/components/galaxy/useGalaxyInteractions.js b/frontend/src/components/galaxy/useGalaxyInteractions.js index 6df39cb..6f3e64c 100644 --- a/frontend/src/components/galaxy/useGalaxyInteractions.js +++ b/frontend/src/components/galaxy/useGalaxyInteractions.js @@ -1,11 +1,10 @@ import { useRef, useCallback } from 'react' import { getPersonMedias } from '../../data/mockData' import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants' -import { nodeRadius, renvoiHitAreas, familyHitAreas, photoPlaceholderHitAreas } from './renderers' +import { nodeRadius, renvoiHitAreas, familyHitAreas } from './renderers' const hitRenvoi = (x, y) => renvoiHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1) const hitFamily = (x, y) => familyHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1) -const hitPhotoPlaceholder = (x, y) => photoPlaceholderHitAreas.find(a => Math.hypot(x - a.cx, y - a.cy) < a.r) import { getEntranceProgress } from './utils' const CURSOR_GRAB = 'default' @@ -23,7 +22,7 @@ export function useGalaxyInteractions({ canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, - getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, onOpenFamily = null, + getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily = null, annotationHandlers, minScale = 0.2, maxScale = 4, @@ -140,7 +139,6 @@ export function useGalaxyInteractions({ } } if (!isOverClickable && (hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y) || hitFamily(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y))) isOverClickable = true - if (!isOverClickable && hitPhotoPlaceholder(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true hoveredNodeIdRef.current = newHoveredNode hoveredMediaKeyRef.current = newHoveredMedia @@ -265,13 +263,6 @@ export function useGalaxyInteractions({ } } - // « + photo » : le clic lance directement la photo (la fiche s'ouvre aussi, côté App) - const photoZone = hitPhotoPlaceholder(clickX, clickY) - if (photoZone && onPhotoPlaceholderClick) { - onPhotoPlaceholderClick(photoZone.personId) - return - } - for (const node of layoutData.children) { if (node._type !== 'person') continue if (ent && !ent.finished) { @@ -300,7 +291,7 @@ export function useGalaxyInteractions({ y: rect2.height / 2 - renvoi.targetY * scale, } } - }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily, annotationHandlers]) + }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onOpenFamily, annotationHandlers]) return { isDragging, isZooming, zoomTimeoutRef, hasDragged,