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,