Immich : vraies miniatures dans le sélecteur, aperçu seulement après un choix

- 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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:16:16 +02:00
1 parent 7f159d5111
commit 6b1f4c067e
4 files changed
+187 -64

No files matched your search

+6
View File
@@ -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}`, {
+21 -11
View File
@@ -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; }
+159 -52
View File
@@ -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 (
<span ref={ref} className={`immich-face ${className}`} aria-hidden="true">
{url ? <img src={url} alt="" /> : <UserRound size={16} aria-hidden="true" />}
</span>
)
}
// 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 }) =>
<p className="immich-empty">{people.length === 0 ? t('Aucune personne dans Immich.') : t('Aucune personne trouvée.')}</p>
) : (
<div className="immich-list" role="listbox" aria-label={t('Personnes Immich')}>
{filteredPeople.map((immichPerson) => (
<button
key={immichPerson.id}
type="button"
role="option"
aria-selected={selected?.id === immichPerson.id}
className={`immich-person ${selected?.id === immichPerson.id ? 'is-active' : ''}`}
onClick={() => handleSelect(immichPerson)}
>
<UserRound size={16} aria-hidden="true" />
<span>{immichPerson.name || t('Sans nom')}</span>
</button>
))}
{filteredPeople.map((immichPerson) => {
const isSelected = selected?.id === immichPerson.id
const isLinked = linkedId === immichPerson.id
return (
<button
key={immichPerson.id}
type="button"
role="option"
aria-selected={isSelected}
className={`immich-person ${isSelected ? 'is-active' : ''} ${isLinked ? 'is-linked' : ''}`}
onClick={() => handleSelect(immichPerson)}
>
<FaceThumb
immichPersonId={immichPerson.id}
hasThumbnail={immichPerson.hasThumbnail}
request={requestThumbnail}
/>
<span className="immich-person-name">{immichPerson.name || t('Sans nom')}</span>
{isLinked && <span className="pz-tag pz-tag--accent immich-person-tag">{t('Associée')}</span>}
</button>
)
})}
</div>
)}
<div className="immich-preview">
{thumbnailUrl
? <img src={thumbnailUrl} alt={selected?.name || ''} />
: <div className="immich-preview-empty"><ImagePlus size={22} aria-hidden="true" /></div>}
<div className="immich-preview-name">{selected?.name || t('Aucune sélection')}</div>
</div>
{selected && (
<div className="immich-preview">
<FaceThumb
key={selected.id}
className="immich-face--preview"
immichPersonId={selected.id}
hasThumbnail={selected.hasThumbnail}
request={requestThumbnail}
eager
/>
<div className="immich-preview-name">{selected.name || t('Sans nom')}</div>
</div>
)}
{error && <div className="pz-error immich-error">{error}</div>}
+1 -1
View File
@@ -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.',