Souvenirs depuis Immich : multi-sélection dans le panneau et retouches de design

Reprend les commits de la PR #103 d'Askrosm (sélecteur Immich).

- Sélecteur : aide retirée sous le titre, aide de recherche déplacée dans le titre du champ, onglets Photos/Vidéos et filtre visage sur une ligne sur mobile, erreur Immich affichée en haut des résultats avec Réessayer.
- Panneau souvenirs : plusieurs fichiers Immich peuvent être cochés, « Terminer la sélection » les ajoute à la liste ; à l'enregistrement, un souvenir est créé par fichier, titré par son nom de fichier.
- Le bouton « Depuis Immich » pleine largeur devient un lien dans la zone de dépôt.
- Cible de sélection portée à 44 px, choix de visage à 44 px minimum.
- Tailles de police et rayon de bordure ramenés à l'échelle de pz.css.
- useMediaManager : handleUploadPersonMedia renvoie true ou false pour arrêter l'envoi à la première erreur.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-21 16:25:18 +02:00
1 parent 0ff143f2c1
commit bfa8d2e01a
6 files changed
+150 -40

No files matched your search

+29 -13
View File
@@ -15,7 +15,7 @@
.iap-types .pz-tab { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.iap-faces { display: grid; gap: 12px; padding: 16px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); }
.iap-face-list { display: flex; gap: 8px; flex-wrap: wrap; max-height: 180px; overflow-y: auto; }
.iap-face-choice { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 5px 12px 5px 5px; border: 1px solid var(--pz-line); border-radius: 999px; background: var(--pz-surface); color: var(--pz-ink-2); font: 500 13px var(--pz-sans); cursor: pointer; }
.iap-face-choice { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 44px; padding: 5px 12px 5px 5px; border: 1px solid var(--pz-line); border-radius: 999px; background: var(--pz-surface); color: var(--pz-ink-2); font: 500 13px var(--pz-sans); cursor: pointer; }
.iap-face-choice > span:not(.iap-face) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.iap-face-choice.is-active { background: var(--pz-a50); border-color: var(--pz-a300); color: var(--pz-a700); }
.iap-face { display: grid; place-items: center; flex: none; width: 30px; height: 30px; overflow: hidden; border-radius: 50%; color: var(--pz-a700); background: var(--pz-a100); }
@@ -28,46 +28,62 @@
.iap-image { position: relative; display: grid; place-items: center; width: 100%; height: 100%; overflow: hidden; background: var(--pz-line-soft); }
.iap-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 220ms var(--pz-ease); }
.iap-image img[hidden] { display: none; }
.iap-placeholder { display: grid; place-items: center; gap: 6px; color: var(--pz-faint); font: 400 11px var(--pz-sans); }
.iap-placeholder { display: grid; place-items: center; gap: 6px; color: var(--pz-faint); font: 400 12px var(--pz-sans); }
.iap-placeholder .pz-busy { width: 40px; height: 24px; }
.iap-zoom { position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--pz-surface); color: var(--pz-ink-2); box-shadow: var(--pz-shadow); }
.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: var(--pz-media-badge-bg); color: var(--pz-surface); font: 500 11px var(--pz-sans); }
.iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 8px 10px; }
.iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12px/1.4 var(--pz-sans); color: var(--pz-muted); }
.iap-select { display: grid; place-items: center; width: 30px; height: 30px; flex: none; padding: 0; background: var(--pz-surface); color: var(--pz-faint); border: 1px solid var(--pz-line); border-radius: 50%; cursor: pointer; }
.iap-select.is-selected { background: var(--pz-a500); border-color: var(--pz-a500); color: var(--pz-surface); }
.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: var(--pz-media-badge-bg); color: var(--pz-surface); font: 500 12px var(--pz-sans); }
.iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 2px 2px 2px 10px; }
.iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12.5px/1.4 var(--pz-sans); color: var(--pz-muted); }
/* Zone de toucher de 44px, pastille visuelle de 30px centrée dedans */
.iap-select { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; background: transparent; color: var(--pz-faint); border: 0; border-radius: 50%; cursor: pointer; }
.iap-select::before { content: ""; position: absolute; top: 50%; left: 50%; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--pz-surface); border: 1px solid var(--pz-line); transition: background-color 160ms ease, border-color 160ms ease; }
.iap-select > svg { position: relative; }
.iap-select.is-selected { color: var(--pz-surface); }
.iap-select.is-selected::before { background: var(--pz-a500); border-color: var(--pz-a500); }
.iap-open:disabled, .iap-select:disabled, .iap-face-choice:disabled { cursor: default; }
.iap-select:disabled:not(.is-selected) { opacity: .45; }
.iap :is(.iap-open, .iap-select, .iap-face-choice, .pz-tab):focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -3px; }
.iap-clear-label { display: inline; }
.iap-empty { display: grid; justify-items: center; align-content: center; min-height: 210px; gap: 12px; color: var(--pz-faint); text-align: center; }
.iap-empty > p { margin: 0; font: 400 23px/1.2 var(--pz-serif); color: var(--pz-ink-2); }
.iap-empty > p { margin: 0; font: 400 15px/1.45 var(--pz-sans); color: var(--pz-ink-2); }
.iap-empty .pz-busy { width: 40px; height: 24px; }
.iap-pagination { display: flex; align-items: center; justify-content: space-between; padding: 0 22px 18px; }
.iap-footer { position: sticky; bottom: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding: 18px 28px; background: var(--pz-surface); border-top: 1px solid var(--pz-line); z-index: 2; }
.iap-footer > p span { display: block; color: var(--pz-faint); font-size: 12px; }
.iap-footer > p span { display: block; color: var(--pz-faint); font-size: 12.5px; }
.iap-error { padding: 0 28px 14px; }
.iap-results > .iap-error { padding: 0 0 14px; }
.iap-error .pz-error { margin: 0; }
.iap-preview-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; }
.iap-preview-stage { height: min(56dvh, 520px); margin: 0 20px; overflow: hidden; border-radius: var(--pz-r-m); background: var(--pz-ink); }
.iap-image--large { background: var(--pz-ink); }
.iap-image--large img { width: 100%; height: 100%; object-fit: contain; }
.iap-preview-caption { display: grid; gap: 4px; padding: 14px 28px 20px; }
.iap-preview-caption > p { margin: 0; font: 400 22px/1.2 var(--pz-serif); color: var(--pz-ink); overflow-wrap: anywhere; }
.iap-preview-caption > p { margin: 0; font: 400 15px/1.4 var(--pz-serif); color: var(--pz-ink); overflow-wrap: anywhere; }
@media (hover: hover) and (pointer: fine) {
.iap-open:hover .iap-image img { transform: scale(1.035); }
.iap-asset:hover { box-shadow: 0 0 0 1px var(--pz-a300), var(--pz-shadow); }
.iap-select:hover:not(:disabled), .iap-face-choice:hover:not(:disabled) { background: var(--pz-a50); color: var(--pz-a700); border-color: var(--pz-a300); }
.iap-select:hover:not(:disabled) { color: var(--pz-a700); }
.iap-select:hover:not(:disabled):not(.is-selected)::before { background: var(--pz-a50); border-color: var(--pz-a300); }
.iap-face-choice:hover:not(:disabled) { background: var(--pz-a50); color: var(--pz-a700); border-color: var(--pz-a300); }
}
@media (max-width: 640px) { .iap-grid { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; } }
@media (max-width: 560px) {
.pz-modal.iap { max-height: 94dvh; border-radius: 26px 26px 0 0; }
.pz-modal.iap { max-height: 94dvh; border-radius: var(--pz-r-l) var(--pz-r-l) 0 0; }
.iap-head { padding: 24px 54px 18px 20px; }
.iap-head .pz-title { font-size: 26px; }
.iap-controls { padding: 18px 20px; gap: 12px; }
.iap-search { border-radius: var(--pz-r-m); flex-wrap: wrap; }
.iap-search input { font-size: 16px; }
.iap-search .pz-btn { min-height: 38px; }
.iap-results { padding: 18px 20px; }
.iap-results { min-height: 0; padding: 18px 20px; }
.iap-results > .iap-error { padding: 0 0 14px; }
/* Vidéos/Photos et filtre visage sur une seule ligne */
.iap-filters { flex-wrap: wrap; gap: 6px; }
.iap-types { flex: 1 1 0; min-width: 0; }
.iap-types .pz-tab { padding: 0 8px; }
.iap-types .pz-tab svg, .iap-clear-label { display: none; }
.iap-face-btn { flex: 0 1 auto; min-width: 0; max-width: 100%; }
.iap-face-btn > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.iap-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
.iap-footer { padding: 16px 20px calc(16px + env(safe-area-inset-bottom)); gap: 10px; }
.iap-footer > .pz-btn { flex: 1; }
@@ -111,13 +111,14 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose })
} catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) }
finally { if (!controller.signal.aborted) setBusy(false) }
}
// Erreur Immich : affichée en haut des résultats (visible sans défiler sur téléphone), ou sous l'aperçu
const errorBlock = <div className="iap-error"><p className="pz-error" role="alert">{error}</p>{!preview && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || loading} onClick={() => setSearch((current) => ({ ...current }))}>{t('Réessayer')}</button>}</div>
return createPortal(<div className="pz-overlay iap-overlay" onClick={(event) => event.stopPropagation()} onMouseDown={(event) => { if (event.target === event.currentTarget && !busy) onClose() }} onKeyDown={handleKey}>
<section ref={dialog} className="pz-modal iap" role="dialog" aria-modal="true" aria-labelledby={`${uid}-title`} tabIndex={-1}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')} disabled={busy}><X size={18} /></button>
<header className="pz-modal-head iap-head">
<p className="pz-eyebrow"><Sparkles size={14} />{t('Depuis Immich')}</p>
<h2 id={`${uid}-title`} className="pz-title">{preview ? t('Un souvenir à regarder de plus près') : t('Retrouver vos souvenirs')}</h2>
<p className="pz-small">{t('Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.')}</p>
</header>
{preview ? <>
<div className="iap-preview-toolbar"><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={closePreview} disabled={busy}><ArrowLeft size={16} />{t('Retour aux photos')}</button><span className="pz-tag">{preview.type === 'VIDEO' ? t('Vidéo') : t('Photo')}</span></div>
@@ -126,15 +127,15 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose })
</> : <>
<div className="iap-controls">
<div className="iap-search" role="search" aria-label={t('Recherche dans Immich')}>
<Search size={18} aria-hidden="true" /><input ref={searchInput} value={draft} maxLength={500} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch() } }} placeholder={t('Un repas au jardin, des vacances à la mer…')} aria-label={t('Décrire la photo recherchée')} disabled={busy} />
<Search size={18} aria-hidden="true" /><input ref={searchInput} value={draft} maxLength={500} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch() } }} placeholder={t('Un repas au jardin, des vacances à la mer…')} title={t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')} aria-label={search.type === 'VIDEO' ? t('Décrire la vidéo recherchée') : t('Décrire la photo recherchée')} disabled={busy} />
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={submitSearch} disabled={busy}>{t('Rechercher')}</button>
</div>
<div className="iap-filters">
<div className="pz-tabs iap-types" role="group" aria-label={t('Type')}>
{[['IMAGE', t('Photos'), Image], ['VIDEO', t('Vidéos'), Video]].map(([value, label, Icon]) => <button key={value} type="button" className={`pz-tab${search.type === value ? ' is-active' : ''}`} aria-pressed={search.type === value} onClick={() => changeSearch({ type: value })} disabled={busy}><Icon size={15} />{label}</button>)}
</div>
<button type="button" className={`pz-btn pz-btn--sm ${search.personId ? 'pz-btn--soft' : 'pz-btn--secondary'}`} aria-expanded={faceOpen} onClick={() => setFaceOpen((value) => !value)} disabled={busy}><UserRound size={16} />{selectedFace?.name || t('Filtrer par visage')}</button>
{(search.query || search.personId) && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => { setDraft(''); changeSearch({ query: '', personId: '' }) }} disabled={busy}><X size={14} />{t('Effacer les filtres')}</button>}
<button type="button" className={`pz-btn pz-btn--sm iap-face-btn ${search.personId ? 'pz-btn--soft' : 'pz-btn--secondary'}`} aria-expanded={faceOpen} onClick={() => setFaceOpen((value) => !value)} disabled={busy}><UserRound size={16} /><span>{selectedFace?.name || t('Filtrer par visage')}</span></button>
{(search.query || search.personId) && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm iap-clear" aria-label={t('Effacer les filtres')} title={t('Effacer les filtres')} onClick={() => { setDraft(''); changeSearch({ query: '', personId: '' }) }} disabled={busy}><X size={14} /><span className="iap-clear-label">{t('Effacer les filtres')}</span></button>}
</div>
{faceOpen && <div className="iap-faces">
<label className="pz-field"><span>{t('Personne reconnue dans Immich')}</span><input value={faceQuery} onChange={(event) => setFaceQuery(event.target.value)} placeholder={t('Rechercher par nom')} disabled={busy} /></label>
@@ -147,9 +148,9 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose })
{people?.length > 0 && filteredPeople.length === 0 && <p className="pz-small">{t('Aucune personne trouvée.')}</p>}
{faceError && <p className="pz-error" role="alert">{faceError}</p>}
</div>}
<p className="pz-hint">{t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')}</p>
</div>
<div className="iap-results" aria-busy={loading}>
{error && !preview && errorBlock}
{loading ? <div className="iap-empty" role="status"><PzBusy busy busyLabel={t('Chargement…')} /><p>{t('À la recherche de vos souvenirs…')}</p></div> : result?.assets.length ? <div className="iap-grid">{result.assets.map((asset) => <article key={asset.id} className={`iap-asset${selected.includes(asset.id) ? ' is-selected' : ''}`}>
<button type="button" className="iap-open" data-asset-id={asset.id} aria-label={t('Agrandir {name}', { name: asset.name })} onClick={() => { previewTrigger.current = asset.id; setPreview(asset); requestAnimationFrame(() => dialog.current?.focus()) }} disabled={busy}>
<AssetImage treeId={treeId} token={token} asset={asset} /><span className="iap-zoom"><ZoomIn size={17} /></span>{asset.type === 'VIDEO' && <span className="iap-video"><Video size={14} />{t('Vidéo')}</span>}
@@ -159,7 +160,7 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose })
</div>
{!loading && result && <nav className="iap-pagination" aria-label={t('Pages des souvenirs Immich')}><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || search.page === 1} onClick={() => setSearch((current) => ({ ...current, page: current.page - 1 }))}><ChevronLeft size={17} />{t('Précédent')}</button><span className="pz-small">{t('Page {page}', { page: search.page })}</span><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || !result.hasNextPage} onClick={() => setSearch((current) => ({ ...current, page: current.page + 1 }))}>{t('Suivant')}<ChevronRight size={17} /></button></nav>}
</>}
{error && <div className="iap-error"><p className="pz-error" role="alert">{error}</p>{!preview && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || loading} onClick={() => setSearch((current) => ({ ...current }))}>{t('Réessayer')}</button>}</div>}
{error && preview && errorBlock}
<footer className="iap-footer"><p className="pz-small">{selected.length === 1 ? t('1 souvenir sélectionné') : selected.length > 1 ? t('{n} souvenirs sélectionnés', { n: selected.length }) : t('Photo : 5 Mo · Vidéo : 20 Mo')}<span>{t('Vos médias seront copiés dans Parenthèse.')}</span></p>
{preview ? <button type="button" className="pz-btn pz-btn--primary" onClick={() => select(preview)} disabled={busy || selected.includes(preview.id)}><Check size={17} /><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{selected.includes(preview.id) ? t('Ajouté') : t('Choisir ce souvenir')}</PzBusy></button> : <button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={busy}><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{t('Terminer la sélection')}</PzBusy></button>}
</footer>
@@ -37,6 +37,11 @@
}
@media (hover: hover) and (pointer: fine) { .mm-drop:hover { background: var(--pz-a100); } }
.mm-drop-input:focus-visible + .mm-drop { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.mm-drop-link { padding: 0; border: 0; background: none; cursor: pointer; font: inherit; font-weight: 500; color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; }
.mm-drop-link:disabled { cursor: default; opacity: .6; }
.mm-immich-queue { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.mm-immich-queue li { display: flex; align-items: center; gap: 10px; min-width: 0; }
.mm-immich-queue span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); }
.mm-drop-icon { width: 40px; height: 40px; flex: none; display: grid; place-items: center; border-radius: 12px; background: var(--pz-surface); color: var(--pz-a700); }
.mm-drop-text { display: grid; gap: 2px; min-width: 0; }
.mm-drop-text strong { font: 500 14px var(--pz-sans); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+98 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { readContributorName } from '../utils/contributorName'
import { ArrowDown, ArrowUp, FileText, Image, MapPin, Mic, Plus, Quote, Route, Trash2, Upload, Video, X } from 'lucide-react'
import { getAcceptForMediaType } from '../utils/mediaUpload'
@@ -46,6 +46,11 @@ const MediaManagerPanel = ({
const [file, setFile] = useState(null)
const [immichConfigured, setImmichConfigured] = useState(false)
const [pickingImmich, setPickingImmich] = useState(false)
// Souvenirs choisis dans Immich : un souvenir par fichier, envoyés l'un après l'autre à l'enregistrement
const [immichFiles, setImmichFiles] = useState([])
const [sending, setSending] = useState(false)
const fileInput = useRef(null)
const immichAvailable = canManage && immichConfigured
useEffect(() => {
let cancelled = false
if (visible && canManage && treeId && token) fetchImmichConfig(treeId, token).then(({ config }) => {
@@ -53,7 +58,7 @@ const MediaManagerPanel = ({
}).catch(() => { if (!cancelled) setImmichConfigured(false) })
return () => { cancelled = true }
}, [visible, canManage, treeId, token])
useEffect(() => { if (!visible) { setPickingImmich(false); setImmichConfigured(false) } }, [visible])
useEffect(() => { if (!visible) { setPickingImmich(false); setImmichConfigured(false); setImmichFiles([]) } }, [visible])
const [mediaType, setMediaType] = useState('photo')
const [citationText, setCitationText] = useState('')
const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube'
@@ -67,7 +72,7 @@ const MediaManagerPanel = ({
const isCitation = mediaType === 'citation'
const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube'
const canUpload = useMemo(() => {
if (loading || pickingImmich) {
if (loading || pickingImmich || sending) {
return false
}
@@ -75,6 +80,10 @@ const MediaManagerPanel = ({
return false
}
if (immichFiles.length > 0) {
return true
}
if (isCitation) {
return citationText.trim().length > 0
}
@@ -84,7 +93,7 @@ const MediaManagerPanel = ({
}
return Boolean(file)
}, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, pickingImmich, youtubeUrl])
}, [canManage, citationText, contributorName, file, immichFiles, isCitation, isYoutubeVideo, loading, pickingImmich, sending, youtubeUrl])
const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType])
if (!visible || !person) {
@@ -97,16 +106,39 @@ const MediaManagerPanel = ({
return
}
await onUpload({
file,
caption: caption.trim() || null,
source: source.trim() || null,
mediaType,
citationText: citationText.trim(),
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
submittedByLabel: canManage ? null : contributorName.trim(),
})
if (immichFiles.length > 0) {
// Un souvenir par fichier Immich, titré par son nom de fichier ; on s'arrête à la première erreur
setSending(true)
let left = immichFiles
for (const item of immichFiles) {
const ok = await onUpload({
file: item.file,
caption: item.file.name.replace(/.[^.]+$/, '') || null,
source: source.trim() || null,
mediaType: item.file.type.startsWith('video/') ? 'video' : 'photo',
citationText: '',
youtubeUrl: null,
submittedByLabel: canManage ? null : contributorName.trim(),
})
if (ok === false) break
left = left.filter((entry) => entry.key !== item.key)
setImmichFiles(left)
}
setSending(false)
if (left.length > 0) return
} else {
await onUpload({
file,
caption: caption.trim() || null,
source: source.trim() || null,
mediaType,
citationText: citationText.trim(),
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
submittedByLabel: canManage ? null : contributorName.trim(),
})
}
setFile(null)
setImmichFiles([])
setCaption('')
setSource('')
setMediaType('photo')
@@ -141,11 +173,21 @@ const MediaManagerPanel = ({
const changeType = (value) => {
setMediaType(value)
setFile(null)
setImmichFiles([])
setCitationText('')
setVideoMode('file')
setYoutubeUrl('')
}
// Un souvenir choisi dans Immich s'ajoute à la liste ; la galerie reste ouverte pour en cocher d'autres
const addImmichFile = (picked) => {
setFile(null)
if (fileInput.current) fileInput.current.value = ''
setImmichFiles((list) => (list.some((entry) => entry.file.name === picked.name && entry.file.size === picked.size)
? list
: [...list, { key: `${picked.name}-${picked.size}`, file: picked }]))
}
return (
<div className="pz-overlay media-manager-modal-overlay" onClick={handleOverlayClick}>
<div className="pz-modal pz-modal--wide media-manager-modal-card" role="dialog" aria-modal="true" aria-labelledby="mediaManagerTitle">
@@ -188,11 +230,8 @@ const MediaManagerPanel = ({
</div>
)}
{canManage && immichConfigured && <button type="button" className="pz-btn pz-btn--soft" disabled={loading} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
{pickingImmich && canManage && <ImmichAssetPicker treeId={treeId} token={token} onClose={() => setPickingImmich(false)} onSelect={(selectedFile) => {
setFile(selectedFile); setMediaType(selectedFile.type.startsWith('video/') ? 'video' : 'photo'); setVideoMode('file'); setPickingImmich(false)
}} />}
{!isCitation && !isYoutubeVideo && (
{pickingImmich && canManage && <ImmichAssetPicker treeId={treeId} token={token} onClose={() => setPickingImmich(false)} onSelect={addImmichFile} />}
{!isCitation && !isYoutubeVideo && !immichAvailable && (
<div className="pz-field">
<label htmlFor="mediaUploadFile" className="mm-drop">
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
@@ -206,10 +245,49 @@ const MediaManagerPanel = ({
className="mm-drop-input"
type="file"
accept={fileAccept}
onChange={(event) => setFile(event.target.files?.[0] || null)}
onChange={(event) => { setFile(event.target.files?.[0] || null); setImmichFiles([]) }}
/>
</div>
)}
{!isCitation && !isYoutubeVideo && immichAvailable && (() => {
// Depuis Immich est un lien dans la zone de dépôt (pas un bouton à part)
const immichLink = <button type="button" className="mm-drop-link" disabled={loading || sending} onClick={() => setPickingImmich(true)}>{t('depuis Immich')}</button>
const count = immichFiles.length
const label = count === 1 ? t('1 souvenir choisi dans Immich') : count > 1 ? t('{n} souvenirs choisis dans Immich', { n: count }) : file ? file.name : null
return (
<div className="pz-field">
<input
id="mediaUploadFile"
ref={fileInput}
className="mm-drop-input"
type="file"
accept={fileAccept}
aria-label={t('Choisir un fichier')}
onChange={(event) => { setFile(event.target.files?.[0] || null); setImmichFiles([]) }}
/>
<div className="mm-drop" onClick={(event) => { if (!event.target.closest('button')) fileInput.current?.click() }}>
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
<span className="mm-drop-text">
<strong>{label || <>{t('Choisir un fichier')} {t('ou')} {immichLink}</>}</strong>
<span>{file ? <>{t('Pour en prendre un autre, touchez ici')} {t('ou')} {immichLink}</> : count > 0 ? <>{t('ou')} {immichLink}</> : (mediaType === 'photo' ? t('5 Mo au maximum') : t('20 Mo au maximum'))}</span>
</span>
</div>
{count > 0 && (
<>
<ul className="mm-immich-queue" aria-label={t('Souvenirs choisis dans Immich')}>
{immichFiles.map((item) => (
<li key={item.key}>
<span>{item.file.name}</span>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setImmichFiles((list) => list.filter((entry) => entry.key !== item.key))} disabled={sending}>{t('Retirer')}</button>
</li>
))}
</ul>
<p className="pz-hint">{t('Chaque souvenir garde le nom de son fichier Immich.')}</p>
</>
)}
</div>
)
})()}
{isYoutubeVideo && (
<div className="pz-field">
@@ -243,6 +321,7 @@ const MediaManagerPanel = ({
<input
id="mediaUploadCaption"
type="text"
disabled={immichFiles.length > 0}
value={caption}
onChange={(event) => setCaption(event.target.value)}
placeholder={t('Été à Quiberon, 1978')}
+4 -2
View File
@@ -62,7 +62,7 @@ export function useMediaManager({
submittedByLabel = null,
}) => {
if (!canUploadMedia || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) {
return
return false
}
// Prénom de la personne qui propose (famille), retenu pour la fois suivante
@@ -105,7 +105,7 @@ export function useMediaManager({
loading: false,
error: validationError,
}))
return
return false
}
setMediaManagerState((current) => ({
@@ -157,12 +157,14 @@ export function useMediaManager({
error: '',
notice: noticeFor(created),
}))
return true
} catch (error) {
setMediaManagerState((current) => ({
...current,
loading: false,
error: getAccountErrorMessage(error),
}))
return false
}
}
+7
View File
@@ -296,6 +296,13 @@ export default {
'Rechercher': 'Search',
'Un repas au jardin, des vacances à la mer…': 'A meal in the garden, a seaside holiday…',
'Décrire la photo recherchée': 'Describe the photo you are looking for',
'Décrire la vidéo recherchée': 'Describe the video you are looking for',
'ou': 'or',
'depuis Immich': 'from Immich',
'1 souvenir choisi dans Immich': '1 memory picked from Immich',
'{n} souvenirs choisis dans Immich': '{n} memories picked from Immich',
'Souvenirs choisis dans Immich': 'Memories picked from Immich',
'Chaque souvenir garde le nom de son fichier Immich.': 'Each memory keeps the name of its Immich file.',
'Filtrer par visage': 'Filter by face',
'Effacer les filtres': 'Clear filters',
'Personne reconnue dans Immich': 'Person recognized in Immich',