import { useEffect, useMemo, useRef, useState } from 'react' import { Check, FolderOpen, ImagePlus, X } from 'lucide-react' import './PhotoStartPanel.css' import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' import { matchPhotoFiles } from '../utils/photoImport.js' import { materializeFile } from '../utils/zipReader.js' import { initialsOf } from '../utils/initial.js' function withEmphasis(text) { const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) return <>{before}{emphasized}{after}> } function lifeYears(person) { if (!person.birthYear) return '' return person.deathYear ? `${person.birthYear} – ${person.deathYear}` : String(person.birthYear) } // Singulier ou pluriel, les deux formes passent par t() function plural(n, one, many, vars = {}) { return n === 1 ? t(one, vars) : t(many, { n, ...vars }) } // Dossier où le logiciel d'origine rangeait la première photo citée, pour aider à le retrouver function folderOf(filePath) { const parts = String(filePath || '').split(/([\\/])/) if (parts.length < 3) return '' return parts.slice(0, -2).join('') } // Après un import GEDCOM (ou depuis les paramètres) : retrouver les photos que le logiciel connaissait, // et demander les premiers visages un par un. // Une référence et sa correspondance retrouvée partagent le chemin cité par le logiciel const refKey = (ref) => `${ref.personId}|${ref.sourcePath ?? ref.file}` export default function PhotoStartPanel({ visible, personsImported = null, photoRefs = [], mediaRefs = [], // Photos et actes d'un zip importé : rapprochés dès l'ouverture, sans demander de dossier bundledFiles = [], faces = [], onUploadOne, onBulkPlace, onBulkMedia, onRefsChange, onMediaRefsChange, onClose, }) { const folderInputRef = useRef(null) const faceInputRef = useRef(null) const faceTargetRef = useRef(null) const [remaining, setRemaining] = useState(photoRefs) const [remainingMedia, setRemainingMedia] = useState(mediaRefs) const [found, setFound] = useState(null) const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 }) const [faceState, setFaceState] = useState({}) const [shownFaces, setShownFaces] = useState(faces) const [placedTotal, setPlacedTotal] = useState(0) const [memoriesTotal, setMemoriesTotal] = useState(0) // Ouvert après l'import d'un zip : les fichiers sont déjà là, on n'explique pas le choix d'un dossier const [zipMode, setZipMode] = useState(false) // La liste des visages et les chemins sont figés à l'ouverture : une ligne ne disparaît pas sous le doigt useEffect(() => { if (!visible) return setRemaining(photoRefs) setRemainingMedia(mediaRefs) setZipMode(bundledFiles.length > 0) if (bundledFiles.length > 0) { const portraits = matchPhotoFiles(photoRefs, bundledFiles) const media = matchPhotoFiles(mediaRefs, bundledFiles) setFound({ matches: portraits.matches, media: media.matches, fromZip: true }) } else { setFound(null) } setBulk({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 }) setFaceState({}) setShownFaces(faces) setPlacedTotal(0) setMemoriesTotal(0) }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!visible) return undefined const onKey = (e) => { if (e.key === 'Escape' && !bulk.running) onClose?.() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [visible, onClose, bulk.running]) // Les aperçus locaux sont libérés quand la fenêtre se ferme const previewsRef = useRef([]) useEffect(() => { if (visible) return undefined previewsRef.current.forEach((url) => URL.revokeObjectURL(url)) previewsRef.current = [] return undefined }, [visible]) const exampleFolder = useMemo(() => folderOf(remaining[0]?.file || remainingMedia[0]?.file), [remaining, remainingMedia]) if (!visible) return null const handleFolder = (event) => { const files = Array.from(event.target.files || []) event.target.value = '' if (files.length === 0) return setBulk((b) => ({ ...b, placed: null, added: 0, failed: 0 })) const portraits = matchPhotoFiles(remaining, files) const media = matchPhotoFiles(remainingMedia, files) setFound({ matches: portraits.matches, media: media.matches }) } const handlePlace = async () => { const mediaMatches = found?.media || [] if ((!found?.matches.length && !mediaMatches.length) || bulk.running) return const portraitCount = found.matches.length const total = portraitCount + mediaMatches.length setBulk({ running: true, done: 0, total, placed: null, added: 0, failed: 0 }) const result = portraitCount ? await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done }))) : { placedIds: [] } const mediaResult = mediaMatches.length && onBulkMedia ? await onBulkMedia(mediaMatches, (done) => setBulk((b) => ({ ...b, done: portraitCount + done }))) : { added: [] } const addedKeys = new Set(mediaResult.added.map(refKey)) const nextRemainingMedia = remainingMedia.filter((ref) => !addedKeys.has(refKey(ref))) setRemainingMedia(nextRemainingMedia) onMediaRefsChange?.(nextRemainingMedia) const placedIds = new Set(result.placedIds) // Une personne de la liste des visages dont la photo vient d'être placée passe en « Ajoutée » const shownIds = new Set(shownFaces.map((p) => String(p.id))) const placedFaces = {} for (const match of found.matches) { if (!placedIds.has(match.personId) || !shownIds.has(String(match.personId))) continue // Photo d'un zip : relue pour l'aperçu (quelques visages au plus) const preview = URL.createObjectURL(await materializeFile(match.file)) previewsRef.current.push(preview) placedFaces[match.personId] = { busy: false, error: '', preview, fromFolder: true } } setFaceState((s) => ({ ...s, ...placedFaces })) const nextRemaining = remaining.filter((ref) => !placedIds.has(ref.personId)) setRemaining(nextRemaining) onRefsChange?.(nextRemaining) setFound(null) setPlacedTotal((n) => n + placedIds.size) setMemoriesTotal((n) => n + addedKeys.size) setBulk({ running: false, done: total, total, placed: placedIds.size, added: addedKeys.size, failed: total - placedIds.size - addedKeys.size }) } const pickFace = (personId) => { faceTargetRef.current = personId faceInputRef.current?.click() } const handleFaceFile = async (event) => { const file = event.target.files?.[0] event.target.value = '' const personId = faceTargetRef.current if (!file || !personId) return setFaceState((s) => ({ ...s, [personId]: { ...s[personId], busy: true, error: '' } })) const error = await onUploadOne(personId, file) setFaceState((s) => { const previous = s[personId] if (error) return { ...s, [personId]: { ...previous, busy: false, error } } const preview = URL.createObjectURL(file) previewsRef.current.push(preview) return { ...s, [personId]: { busy: false, error: '', preview } } }) } const refsKnown = photoRefs.length > 0 || mediaRefs.length > 0 const somethingLeft = remaining.length > 0 || remainingMedia.length > 0 const foundMedia = found?.media || [] const foundTotal = (found?.matches.length || 0) + foundMedia.length const intro = () => { if (zipMode) { return t('Votre zip contient le fichier .ged et les photos ou actes qu’il cite. Parenthèse les a retrouvés, il ne reste qu’à les ajouter. Les portraits vont sur chaque fiche, le reste rejoint les souvenirs.') } if (remaining.length > 0 && remainingMedia.length > 0) { const others = plural(remainingMedia.length, '1 autre fichier, un acte ou une photo de famille', '{n} autres fichiers, actes ou photos de famille') return remaining.length === 1 ? t('Votre logiciel associait une photo à 1 personne, et citait {others}. Choisissez le dossier où il les range, Parenthèse place le portrait et ajoute le reste aux souvenirs.', { others }) : t('Votre logiciel associait une photo à {n} personnes, et citait {others}. Choisissez le dossier où il les range, Parenthèse place les portraits et ajoute le reste aux souvenirs.', { n: remaining.length, others }) } if (remainingMedia.length > 0) { return plural(remainingMedia.length, 'Votre logiciel citait 1 fichier, un acte ou une photo de famille. Choisissez le dossier où il le range, Parenthèse l’ajoute aux souvenirs.', 'Votre logiciel citait {n} fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse les ajoute aux souvenirs de chaque personne.') } return plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.') } const foundSummary = () => { if (foundMedia.length === 0) return plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length }) if (found.matches.length === 0) return plural(foundMedia.length, '1 fichier retrouvé sur {k}.', '{n} fichiers retrouvés sur {k}.', { k: remainingMedia.length }) return t('{portraits} et {files} retrouvés.', { portraits: plural(found.matches.length, '1 portrait', '{n} portraits'), files: plural(foundMedia.length, '1 autre fichier', '{n} autres fichiers'), }) } const addedCount = Object.values(faceState).filter((s) => s?.preview && !s.fromFolder).length + placedTotal return (
{t('Import terminé')}
}{personsImported != null ? t('{n} personnes sont dans votre arbre. Avec une photo, la famille se reconnaît tout de suite.', { n: personsImported.toLocaleString() }) : t('Avec une photo, la famille se reconnaît tout de suite.')}
{intro()}
{exampleFolder && !zipMode && ({t('Il les rangeait dans {folder}', { folder: exampleFolder })}
)} > ) : ({t('Toutes les photos de votre logiciel sont placées.')}
)} {bulk.running ? ({t('Envoi de {done} sur {total}', { done: bulk.done, total: bulk.total })}
{foundSummary()}
{found.fromZip ? t('Aucun fichier du zip ne correspond à ceux que votre logiciel citait. Choisissez le dossier où il les range.') : t("Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.")}
> )}{(bulk.placed > 0 || bulk.added === 0) && plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')} {bulk.added > 0 && `${bulk.placed > 0 ? ' ' : ''}${plural(bulk.added, '1 fichier ajouté aux souvenirs.', '{n} fichiers ajoutés aux souvenirs.')}`} {bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`}
)} {!bulk.running && somethingLeft && ({remainingMedia.length > 0 ? t('Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.') : t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}
)}{memoriesTotal > 0 ? plural(addedCount + memoriesTotal, '1 fichier ajouté.', '{n} fichiers ajoutés.') : addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')}