diff --git a/frontend/src/api/immichApi.js b/frontend/src/api/immichApi.js index 7da570a..9f8a9a2 100644 --- a/frontend/src/api/immichApi.js +++ b/frontend/src/api/immichApi.js @@ -59,6 +59,12 @@ export async function listImmichPeople(treeId, token) { return Array.isArray(data?.people) ? data.people : [] } +// Personne Immich déjà associée à cette fiche (administrateurs seulement), ou null. +export async function fetchImmichLink(treeId, personId, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/links/${encodeURIComponent(personId)}`, withAuth({}, token)) + return data?.immichPersonId || null +} + export async function fetchImmichThumbnail(treeId, immichPersonId, token) { const path = `/trees/${encodeURIComponent(treeId)}/immich/thumbnail/${encodeURIComponent(immichPersonId)}` const response = await fetch(`${API_BASE_URL}${path}`, { diff --git a/frontend/src/components/ImmichFacePanel.css b/frontend/src/components/ImmichFacePanel.css index 9706ac1..762f132 100644 --- a/frontend/src/components/ImmichFacePanel.css +++ b/frontend/src/components/ImmichFacePanel.css @@ -50,7 +50,7 @@ display: flex; align-items: center; gap: 10px; - padding: 9px 10px; + padding: 7px 10px; border: 1px solid transparent; border-radius: calc(var(--pz-r-m) - 3px); background: var(--pz-surface); @@ -61,19 +61,29 @@ transition: background-color 140ms ease, color 140ms ease; } .immich-person.is-active { border-color: var(--pz-a500); background: var(--pz-a50); color: var(--pz-a700); } +.immich-person.is-linked:not(.is-active) { border-color: var(--pz-a300); } +.immich-person-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.immich-person-tag { margin-left: auto; flex: none; } @media (hover: hover) and (pointer: fine) { .immich-person:hover:not(.is-active) { background: var(--pz-a50); } } -.immich-preview { - display: grid; - justify-items: center; - gap: 8px; - padding: 12px; - border: 1px dashed var(--pz-line); - border-radius: var(--pz-r-m); +/* Miniature ronde d'un visage ; en attente ou sans vignette, un rond neutre avec l'icône. */ +.immich-face { + width: 34px; height: 34px; flex: none; display: grid; place-items: center; overflow: hidden; + border-radius: 50%; border: 1px solid var(--pz-line); background: var(--pz-bg); color: var(--pz-faint); } -.immich-preview img { width: 96px; height: 96px; object-fit: cover; border-radius: 50%; } -.immich-preview-empty { width: 96px; height: 96px; display: grid; place-items: center; border-radius: 50%; background: var(--pz-surface); color: var(--pz-faint); } -.immich-preview-name { font: 500 13px var(--pz-sans); color: var(--pz-faint); } +.immich-face img { width: 100%; height: 100%; object-fit: cover; display: block; } + +/* Aperçu du visage choisi : même photo que les fiches (carré arrondi), n'apparaît qu'après un choix. */ +.immich-preview { + display: flex; align-items: center; gap: 14px; padding: 12px 14px; + border-radius: var(--pz-r-m); background: var(--pz-a50); +} +.immich-face--preview { + width: 64px; height: 64px; border-radius: 24%; border: 0; + background: var(--pz-a100); color: var(--pz-a700); + box-shadow: 0 0 0 1px rgba(42, 38, 34, .06), var(--pz-shadow); +} +.immich-preview-name { font: 500 14px var(--pz-sans); color: var(--pz-ink-2); } .immich-empty { font: 500 13px var(--pz-sans); color: var(--pz-faint); text-align: center; padding: 12px 0; } .immich-error { margin: 0; } diff --git a/frontend/src/components/ImmichFacePanel.jsx b/frontend/src/components/ImmichFacePanel.jsx index e029727..c3a1223 100644 --- a/frontend/src/components/ImmichFacePanel.jsx +++ b/frontend/src/components/ImmichFacePanel.jsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { ImagePlus, Loader2, Search, Unlink, UserRound, X } from 'lucide-react' +import { Loader2, Search, Unlink, UserRound, X } from 'lucide-react' import { createPortal } from 'react-dom' import { assignImmichPerson, checkImmichStatus, + fetchImmichLink, fetchImmichThumbnail, getImmichErrorMessage, listImmichPeople, @@ -14,6 +15,119 @@ import { t } from '../i18n/index.js' import PzBusy from './PzBusy.jsx' import './ImmichFacePanel.css' +// Nombre de miniatures chargées en même temps : une liste peut compter beaucoup de visages. +const MAX_PARALLEL_THUMBNAILS = 3 + +// Miniatures du panneau : une requête par visage, mise en cache le temps du panneau. Les URL +// blob sont révoquées au démontage. Renvoie request(immichPersonId) -> URL blob, ou null. +function useFaceThumbnails(treeId, token) { + const cacheRef = useRef(new Map()) // immichPersonId -> URL blob, ou null (pas de vignette) + const inflightRef = useRef(new Map()) // immichPersonId -> promesse en attente + const queueRef = useRef([]) + const runningRef = useRef(0) + const mountedRef = useRef(true) + + const pump = useCallback(() => { + while (runningRef.current < MAX_PARALLEL_THUMBNAILS && queueRef.current.length > 0) { + const job = queueRef.current.shift() + runningRef.current += 1 + job().finally(() => { + runningRef.current -= 1 + pump() + }) + } + }, []) + + const request = useCallback((immichPersonId) => { + if (cacheRef.current.has(immichPersonId)) return Promise.resolve(cacheRef.current.get(immichPersonId)) + if (inflightRef.current.has(immichPersonId)) return inflightRef.current.get(immichPersonId) + + const promise = new Promise((resolve) => { + queueRef.current.push(async () => { + let url = null + if (mountedRef.current) { + try { + url = await fetchImmichThumbnail(treeId, immichPersonId, token) + } catch { + url = null + } + } + inflightRef.current.delete(immichPersonId) + if (!mountedRef.current) { + if (url) URL.revokeObjectURL(url) + resolve(null) + return + } + cacheRef.current.set(immichPersonId, url) + resolve(url) + }) + }) + inflightRef.current.set(immichPersonId, promise) + pump() + return promise + }, [treeId, token, pump]) + + useEffect(() => { + mountedRef.current = true + const cache = cacheRef.current + // La file et les promesses en attente ne sont pas vidées : elles se résolvent à vide dès + // qu'elles trouvent le panneau démonté, et restent valables si le montage reprend (StrictMode). + return () => { + mountedRef.current = false + cache.forEach((url) => { if (url) URL.revokeObjectURL(url) }) + cache.clear() + } + }, []) + + return request +} + +// Vrai une fois l'élément affiché (ou tout de suite si eager). Sans IntersectionObserver, vrai. +function useInView(ref, eager) { + const [inView, setInView] = useState(eager) + + useEffect(() => { + if (inView) return undefined + const element = ref.current + if (!element) return undefined + if (typeof IntersectionObserver === 'undefined') { + setInView(true) + return undefined + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setInView(true) + observer.disconnect() + } + }, { rootMargin: '120px' }) + observer.observe(element) + return () => observer.disconnect() + }, [inView, ref]) + + return inView +} + +// Visage d'une personne Immich : sa miniature ronde, chargée à l'affichage. En attente, ou sans +// vignette, un rond neutre avec l'icône. +const FaceThumb = ({ immichPersonId, hasThumbnail, request, eager = false, className = '' }) => { + const ref = useRef(null) + const inView = useInView(ref, eager) + const [url, setUrl] = useState(null) + + useEffect(() => { + if (!inView || !hasThumbnail || !immichPersonId) return undefined + let cancelled = false + request(immichPersonId).then((loaded) => { if (!cancelled) setUrl(loaded) }) + return () => { cancelled = true } + }, [inView, hasThumbnail, immichPersonId, request]) + + return ( + + ) +} + // Associer une personne de l'arbre à une personne Immich : sa vignette devient le portrait. // Réservé aux administrateurs de l'arbre (l'API vérifie le rôle côté serveur). const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => { @@ -21,25 +135,16 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => const [unavailableReason, setUnavailableReason] = useState('') const [people, setPeople] = useState([]) const [loadingPeople, setLoadingPeople] = useState(false) + const [linkedId, setLinkedId] = useState(null) // personne Immich déjà associée à cette fiche const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) - const [thumbnailUrl, setThumbnailUrl] = useState(null) const [actionLoading, setActionLoading] = useState(false) const [confirmReplace, setConfirmReplace] = useState(false) const [error, setError] = useState('') - const thumbnailRef = useRef(null) - - const releaseThumbnail = useCallback(() => { - if (thumbnailRef.current) { - URL.revokeObjectURL(thumbnailRef.current) - thumbnailRef.current = null - } - setThumbnailUrl(null) - }, []) + const requestThumbnail = useFaceThumbnails(treeId, token) useEffect(() => { let cancelled = false - releaseThumbnail() const load = async () => { try { @@ -50,9 +155,13 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => if (!status?.available) return setLoadingPeople(true) - const immichPeople = await listImmichPeople(treeId, token) + const [immichPeople, link] = await Promise.all([ + listImmichPeople(treeId, token), + fetchImmichLink(treeId, person.id, token).catch(() => null), + ]) if (cancelled) return setPeople(immichPeople) + setLinkedId(link) } catch (err) { if (cancelled) return setAvailable(false) @@ -65,31 +174,13 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => load() return () => { cancelled = true } - }, [treeId, token, releaseThumbnail]) - - useEffect(() => releaseThumbnail, [releaseThumbnail]) - - const loadThumbnail = useCallback(async (immichPersonId) => { - releaseThumbnail() - try { - const url = await fetchImmichThumbnail(treeId, immichPersonId, token) - thumbnailRef.current = url - setThumbnailUrl(url) - } catch { - setThumbnailUrl(null) - } - }, [treeId, token, releaseThumbnail]) + }, [treeId, token, person.id]) const handleSelect = useCallback((immichPerson) => { setSelected(immichPerson) setError('') setConfirmReplace(false) - if (immichPerson?.hasThumbnail && immichPerson?.id) { - loadThumbnail(immichPerson.id) - } else { - releaseThumbnail() - } - }, [loadThumbnail, releaseThumbnail]) + }, []) const handleAssign = useCallback(async (replaceAvatar = false) => { if (!selected || actionLoading) return @@ -194,28 +285,44 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) =>

{people.length === 0 ? t('Aucune personne dans Immich.') : t('Aucune personne trouvée.')}

) : (
- {filteredPeople.map((immichPerson) => ( - - ))} + {filteredPeople.map((immichPerson) => { + const isSelected = selected?.id === immichPerson.id + const isLinked = linkedId === immichPerson.id + return ( + + ) + })}
)} -
- {thumbnailUrl - ? {selected?.name - :
} -
{selected?.name || t('Aucune sélection')}
-
+ {selected && ( +
+ +
{selected.name || t('Sans nom')}
+
+ )} {error &&
{error}
} diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index e13441c..515c3ee 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -285,7 +285,7 @@ export default { 'Aucune personne trouvée.': 'No people found.', 'Personnes Immich': 'Immich people', 'Sans nom': 'Unnamed', - 'Aucune sélection': 'No selection', + 'Associée': 'Linked', 'Associer et prendre le portrait': 'Link and use as portrait', 'Un portrait est déjà en place. Associer cette personne Immich remplacera le portrait actuel et supprimera son fichier.': 'A portrait is already in place. Linking this Immich person will replace the current portrait and delete its file.',