diff --git a/frontend/src/components/ImmichAssetPicker.css b/frontend/src/components/ImmichAssetPicker.css index f676a37..6e7d3c8 100644 --- a/frontend/src/components/ImmichAssetPicker.css +++ b/frontend/src/components/ImmichAssetPicker.css @@ -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; } diff --git a/frontend/src/components/ImmichAssetPicker.jsx b/frontend/src/components/ImmichAssetPicker.jsx index accf56e..b989ebf 100644 --- a/frontend/src/components/ImmichAssetPicker.jsx +++ b/frontend/src/components/ImmichAssetPicker.jsx @@ -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 =

{error}

{!preview && }
return createPortal(
event.stopPropagation()} onMouseDown={(event) => { if (event.target === event.currentTarget && !busy) onClose() }} onKeyDown={handleKey}>

{t('Depuis Immich')}

{preview ? t('Un souvenir à regarder de plus près') : t('Retrouver vos souvenirs')}

-

{t('Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.')}

{preview ? <>
{preview.type === 'VIDEO' ? t('Vidéo') : t('Photo')}
@@ -126,15 +127,15 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose }) : <>
-
{[['IMAGE', t('Photos'), Image], ['VIDEO', t('Vidéos'), Video]].map(([value, label, Icon]) => )}
- - {(search.query || search.personId) && } + + {(search.query || search.personId) && }
{faceOpen &&
@@ -147,9 +148,9 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose }) {people?.length > 0 && filteredPeople.length === 0 &&

{t('Aucune personne trouvée.')}

} {faceError &&

{faceError}

}
} -

{t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')}

+ {error && !preview && errorBlock} {loading ?

{t('À la recherche de vos souvenirs…')}

: result?.assets.length ?
{result.assets.map((asset) =>
{!loading && result && } } - {error &&

{error}

{!preview && }
} + {error && preview && errorBlock}

{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')}{t('Vos médias seront copiés dans Parenthèse.')}

{preview ? : }
diff --git a/frontend/src/components/MediaManagerPanel.css b/frontend/src/components/MediaManagerPanel.css index 5f869e9..246d6ed 100644 --- a/frontend/src/components/MediaManagerPanel.css +++ b/frontend/src/components/MediaManagerPanel.css @@ -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; } diff --git a/frontend/src/components/MediaManagerPanel.jsx b/frontend/src/components/MediaManagerPanel.jsx index 789f101..f9ce9a7 100644 --- a/frontend/src/components/MediaManagerPanel.jsx +++ b/frontend/src/components/MediaManagerPanel.jsx @@ -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 (
@@ -188,11 +230,8 @@ const MediaManagerPanel = ({
)} - {canManage && immichConfigured && } - {pickingImmich && canManage && setPickingImmich(false)} onSelect={(selectedFile) => { - setFile(selectedFile); setMediaType(selectedFile.type.startsWith('video/') ? 'video' : 'photo'); setVideoMode('file'); setPickingImmich(false) - }} />} - {!isCitation && !isYoutubeVideo && ( + {pickingImmich && canManage && setPickingImmich(false)} onSelect={addImmichFile} />} + {!isCitation && !isYoutubeVideo && !immichAvailable && (
)} + {!isCitation && !isYoutubeVideo && immichAvailable && (() => { + // Depuis Immich est un lien dans la zone de dépôt (pas un bouton à part) + const immichLink = + 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 ( +
+ { setFile(event.target.files?.[0] || null); setImmichFiles([]) }} + /> +
{ if (!event.target.closest('button')) fileInput.current?.click() }}> + + + {label || <>{t('Choisir un fichier')} {t('ou')} {immichLink}} + {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'))} + +
+ {count > 0 && ( + <> +
    + {immichFiles.map((item) => ( +
  • + {item.file.name} + +
  • + ))} +
+

{t('Chaque souvenir garde le nom de son fichier Immich.')}

+ + )} +
+ ) + })()} {isYoutubeVideo && (
@@ -243,6 +321,7 @@ const MediaManagerPanel = ({ 0} value={caption} onChange={(event) => setCaption(event.target.value)} placeholder={t('Été à Quiberon, 1978')} diff --git a/frontend/src/hooks/useMediaManager.js b/frontend/src/hooks/useMediaManager.js index 58f820a..8318021 100644 --- a/frontend/src/hooks/useMediaManager.js +++ b/frontend/src/hooks/useMediaManager.js @@ -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 } } diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index f1f4ca7..f3bcb2d 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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',