mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 08:32:03 +00:00
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:
1 parent
7f159d5111
commit
6b1f4c067e
4 files changed
+187
-64
No files matched your search
@@ -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}`, {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in new issue
Block a user