From 6b1f4c067e847378c79edc32645fa5a34375bb33 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 18:14:57 +0200 Subject: [PATCH] =?UTF-8?q?Immich=20:=20vraies=20miniatures=20dans=20le=20?= =?UTF-8?q?s=C3=A9lecteur,=20aper=C3=A7u=20seulement=20apr=C3=A8s=20un=20c?= =?UTF-8?q?hoix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Les visages affichent leur miniature ronde au lieu de l'icône. Chargées quand elles entrent dans la liste (IntersectionObserver), trois requêtes au plus en parallèle, une requête par visage, cache le temps du panneau. Les URL blob sont révoquées au démontage. En attente ou sans vignette : un rond neutre. - La personne déjà associée à la fiche est marquée « Associée » (lecture réservée aux administrateurs, via la nouvelle route de lien). - Le cadre pointillé vide disparaît : l'aperçu n'apparaît qu'une fois un visage choisi, avec la photo carrée arrondie des fiches. Le bouton principal reste désactivé tant que rien n'est choisi. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/immichApi.js | 6 + frontend/src/components/ImmichFacePanel.css | 32 ++- frontend/src/components/ImmichFacePanel.jsx | 211 +++++++++++++++----- frontend/src/i18n/en/fiche.js | 2 +- 4 files changed, 187 insertions(+), 64 deletions(-) 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.',