diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5043b51..dde5d49 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -51,7 +51,7 @@ import { reorderSiblings, uploadPersonMedia, } from './api/treeApi' -import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' +import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' import { useAuth } from './hooks/useAuth' import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess' import { useMediaManager } from './hooks/useMediaManager' @@ -1686,6 +1686,51 @@ 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() }) @@ -3135,10 +3180,14 @@ function App() { focusPersonId={tree.treeContext.rootPersonId} assetRevision={tree.assetRevision} photoHint={tree.canEditCurrentTree} + onPhotoPlaceholderClick={tree.canEditCurrentTree ? handlePhotoPlaceholderClick : null} reorderEnabled={canReorderSiblings} onReorderSiblings={handleReorderSiblings} /> + {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 85f5574..805e834 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -40,7 +40,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, reorderEnabled = false, onReorderSiblings = null }) => { +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 }) => { const canvasRef = useRef(null) // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours const focusPersonIdRef = useRef(focusPersonId) @@ -139,7 +139,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos, - getAnimatedPos, onPersonSelect, onMediaSelect, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers, minScale: MIN_SCALE, maxScale: MAX_SCALE, diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 22c26f7..725578d 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -52,6 +52,10 @@ const PersonCard = ({ onAvatarUpload, onAvatarDelete, onAvatarCrop, + pendingPortraitCrop = null, + onPendingPortraitCropDone = null, + photoSourceRequest = null, + onPhotoSourceRequestDone = null, treeId = null, immichToken = null, onImmichChange, @@ -176,16 +180,19 @@ const PersonCard = ({ // Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se // traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source. // Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur. + // Dépend de la présence de onImmichChange, pas de sa valeur : App le recrée à chaque rendu, ce qui + // relançait la lecture de la config et refermait le menu des sources juste après son ouverture + const canChangeImmich = Boolean(onImmichChange) useEffect(() => { let cancelled = false setImmichConfigured(null) setShowSourceMenu(false) - if (!editMode || !isAdmin || !treeId || !onImmichChange || !immichToken) return undefined + if (!editMode || !isAdmin || !treeId || !canChangeImmich || !immichToken) return undefined fetchImmichConfig(treeId, immichToken) .then(({ config }) => { if (!cancelled) setImmichConfigured(Boolean(config?.configured)) }) .catch(() => { if (!cancelled) setImmichConfigured(false) }) return () => { cancelled = true } - }, [editMode, isAdmin, treeId, immichToken, onImmichChange]) + }, [editMode, isAdmin, treeId, immichToken, canChangeImmich]) useEffect(() => { if (!showSourceMenu) return undefined @@ -246,6 +253,21 @@ 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 3c8dd8f..8a93de2 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -201,6 +201,8 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD // useGalaxyInteractions. // ============================================================ export const renvoiHitAreas = [] +// Zones « + photo » : portraits vides, chez le propriétaire (cercle du nœud, rempli au dessin) +export const photoPlaceholderHitAreas = [] const renvoiPills = [] const RENVOI_FONT = '500 11px "DM Sans", system-ui, sans-serif' // Pastilles collées aux personnes : juste sous le couple, juste au-dessus du portrait de l'enfant @@ -450,6 +452,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { 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 @@ -1043,6 +1049,7 @@ 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 2273403..f149fa9 100644 --- a/frontend/src/components/galaxy/useGalaxyInteractions.js +++ b/frontend/src/components/galaxy/useGalaxyInteractions.js @@ -1,9 +1,10 @@ import { useRef, useCallback } from 'react' import { getPersonMedias } from '../../data/mockData' import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants' -import { nodeRadius, renvoiHitAreas } from './renderers' +import { nodeRadius, renvoiHitAreas, photoPlaceholderHitAreas } from './renderers' const hitRenvoi = (x, y) => renvoiHitAreas.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' @@ -21,7 +22,7 @@ export function useGalaxyInteractions({ canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, - getAnimatedPos, onPersonSelect, onMediaSelect, + getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null, annotationHandlers, minScale = 0.2, maxScale = 4, @@ -138,6 +139,7 @@ export function useGalaxyInteractions({ } } if (!isOverClickable && hitRenvoi(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 @@ -262,6 +264,13 @@ 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) { @@ -286,7 +295,7 @@ export function useGalaxyInteractions({ y: rect2.height / 2 - renvoi.targetY * scale, } } - }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, annotationHandlers]) + }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers]) return { isDragging, isZooming, zoomTimeoutRef, hasDragged,