Files
parenthese/frontend/src/components/TreeCreationWizard.jsx
T
IliesAllaliandClaude Opus 5.5 83443ac9dc Premières photos : import dès la création, photos du logiciel retrouvées, premiers visages
- Création d'arbre : « J'ai déjà un arbre » importe le .ged sans fiche « vous » en double.
- Import GEDCOM : lit les photos citées (OBJE / FILE, en ligne ou par renvoi, _PRIM d'abord)
  et renvoie leur chemin ; rien n'est gardé côté serveur.
- Fenêtre « Il manque les visages » après un import : dossier des photos apparié par nom de
  fichier, cinq visages à demander (famille de « Moi », sinon les plus jeunes).
- Portrait vide chez le propriétaire : cadre cliquable sur la fiche hors édition, « + photo »
  au survol sur l'arbre.
- Photos de plus de 5 Mo réduites à 2000 px avant l'envoi ; portraits à 120 envois par minute.
- PostHog : gedcom_imported, portraits_bulk_placed, portrait_added (effectifs arrondis).
- Corrigé : une union à un seul parent connu relie ses enfants à ce parent, sous un seul
  « Inconnu », au lieu de les laisser sans lien.
- Corrigé : réimporter un GEDCOM reprend les personnes, unions et liens déjà présents au lieu
  de tout doubler.

Recette locale (vraie API, vraie base) : 29/29 sur le parcours photos, 47/47 sur la recette
de non-régression des parcours. Tests 123 serveur, 107 app, lint et build propres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:54:56 +02:00

255 lines
10 KiB
React

import { useEffect, useMemo, useState } from 'react'
import { FileUp, ImagePlus } from 'lucide-react'
import './TreeCreationWizard.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
const INITIAL_STATE = {
treeName: '',
selfFirstName: '',
selfLastName: '',
selfBirthYear: '',
photoFile: null,
// « J'ai déjà un arbre » : fichier GEDCOM exporté d'un logiciel, importé à la création
start: 'self',
gedcomFile: null,
}
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
function TreeCreationWizard({
visible,
loading,
defaultFirstName = '',
onClose,
onSubmit,
}) {
const [step, setStep] = useState(1)
const [form, setForm] = useState(INITIAL_STATE)
const [error, setError] = useState('')
useEffect(() => {
if (!visible) {
return
}
setStep(1)
setError('')
setForm((current) => ({
...INITIAL_STATE,
treeName: current.treeName || '',
selfFirstName: defaultFirstName || '',
}))
}, [defaultFirstName, visible])
const canContinue = useMemo(() => form.treeName.trim().length >= 2 && !loading, [form.treeName, loading])
const canCreate = useMemo(() => {
if (loading) return false
if (form.start === 'import') return Boolean(form.gedcomFile)
return form.selfFirstName.trim().length > 0 && form.selfLastName.trim().length > 0
}, [form.selfFirstName, form.selfLastName, form.start, form.gedcomFile, loading])
if (!visible) {
return null
}
const updateField = (key, value) => {
setForm((current) => ({ ...current, [key]: value }))
}
const handleContinue = () => {
if (!canContinue) return
setError('')
setStep(2)
}
const handleCreate = async (event) => {
event.preventDefault()
if (!canCreate) return
setError('')
if (form.start === 'import') {
const submitError = await onSubmit({ treeName: form.treeName.trim(), gedcomFile: form.gedcomFile })
if (submitError) setError(submitError)
return
}
const submitError = await onSubmit({
treeName: form.treeName.trim(),
selfFirstName: form.selfFirstName.trim(),
selfLastName: form.selfLastName.trim(),
selfBirthYear: form.selfBirthYear.trim(),
photoFile: form.photoFile || null,
})
if (submitError) {
setError(submitError)
}
}
return (
<div className="pz-overlay tree-wizard-overlay" role="dialog" aria-modal="true" aria-labelledby="treeWizardTitle">
<div className="pz-modal tree-wizard-card">
<div className="tw-steps" aria-label={t('Étape {step} sur 2', { step })}>
<span className={`tw-step ${step >= 1 ? 'is-on' : ''}`} />
<span className={`tw-step ${step >= 2 ? 'is-on' : ''}`} />
<span className="pz-small">{t('Étape {step} sur 2', { step })}</span>
</div>
{step === 1 ? (
<>
<div className="pz-modal-head">
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t("Comment s'appelle <em>votre famille</em> ?"))}</h2>
<p className="pz-sub">{t("Ce nom apparaîtra en haut de l'arbre et dans le lien que vous partagerez.")}</p>
</div>
<div className="pz-field">
<label htmlFor="wizardTreeName">{t('Nom de la famille')}</label>
<input
id="wizardTreeName"
type="text"
autoFocus
maxLength={120}
value={form.treeName}
onChange={(event) => updateField('treeName', event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); handleContinue() } }}
placeholder={t('Famille Martin')}
required
/>
</div>
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={loading}>
{t('Fermer')}
</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={handleContinue} disabled={!canContinue}>
{t('Continuer')}
</button>
</div>
</>
) : (
<form onSubmit={handleCreate} className="tw-form">
<div className="pz-tabs tw-start" role="tablist" aria-label={t('Point de départ')}>
<button type="button" role="tab" aria-selected={form.start === 'self'} className={`pz-tab ${form.start === 'self' ? 'is-active' : ''}`} onClick={() => updateField('start', 'self')}>
{t('Je pars de moi')}
</button>
<button type="button" role="tab" aria-selected={form.start === 'import'} className={`pz-tab ${form.start === 'import' ? 'is-active' : ''}`} onClick={() => updateField('start', 'import')}>
{t("J'ai déjà un arbre")}
</button>
</div>
{form.start === 'import' ? (
<>
<div className="pz-modal-head">
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Importez <em>votre arbre</em>'))}</h2>
<p className="pz-sub">{t('Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.')}</p>
</div>
<div className="pz-field">
<label htmlFor="wizardGedcom" className="tw-photo">
<span className="tw-photo-icon" aria-hidden="true"><FileUp size={20} strokeWidth={1.8} /></span>
<span className="tw-photo-text">
<strong>{form.gedcomFile ? form.gedcomFile.name : t('Choisir le fichier .ged')}</strong>
<span>{form.gedcomFile ? t('Cliquez pour en choisir un autre') : t('Exporté depuis le logiciel où vous tenez votre arbre')}</span>
</span>
</label>
<input
id="wizardGedcom"
className="tw-photo-input"
type="file"
accept=".ged,text/x-gedcom,text/plain"
onChange={(event) => updateField('gedcomFile', event.target.files?.[0] || null)}
/>
</div>
</>
) : (
<>
<div className="pz-modal-head">
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Commencez <em>par vous</em>'))}</h2>
<p className="pz-sub">{t("L'arbre « {name} » démarre avec votre fiche. Vous ajouterez les autres ensuite.", { name: form.treeName.trim() })}</p>
</div>
<div className="pz-row2">
<div className="pz-field">
<label htmlFor="wizardSelfFirstName">{t('Votre prénom')}</label>
<input
id="wizardSelfFirstName"
type="text"
maxLength={120}
autoComplete="given-name"
value={form.selfFirstName}
onChange={(event) => updateField('selfFirstName', event.target.value)}
required
/>
</div>
<div className="pz-field">
<label htmlFor="wizardSelfLastName">{t('Votre nom')}</label>
<input
id="wizardSelfLastName"
type="text"
maxLength={120}
autoComplete="family-name"
value={form.selfLastName}
onChange={(event) => updateField('selfLastName', event.target.value)}
required
/>
</div>
</div>
<div className="pz-field">
<label htmlFor="wizardSelfBirthYear">{t('Année de naissance')} <span className="tw-optional">{t('facultatif')}</span></label>
<input
id="wizardSelfBirthYear"
type="number"
inputMode="numeric"
min="1000"
max="2999"
value={form.selfBirthYear}
onChange={(event) => updateField('selfBirthYear', event.target.value)}
placeholder="1990"
/>
</div>
<div className="pz-field">
<span className="pz-label">{t('Votre photo')} <span className="tw-optional">{t('facultatif')}</span></span>
<label htmlFor="wizardSelfPhoto" className="tw-photo">
<span className="tw-photo-icon" aria-hidden="true"><ImagePlus size={20} strokeWidth={1.8} /></span>
<span className="tw-photo-text">
<strong>{form.photoFile ? form.photoFile.name : t('Choisir une photo')}</strong>
<span>{form.photoFile ? t('Cliquez pour en choisir une autre') : t("Un portrait où l'on voit bien votre visage")}</span>
</span>
</label>
<input
id="wizardSelfPhoto"
className="tw-photo-input"
type="file"
accept="image/*"
onChange={(event) => updateField('photoFile', event.target.files?.[0] || null)}
/>
</div>
</>
)}
{error && <div className="pz-error tree-wizard-error" role="alert">{error}</div>}
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setStep(1)} disabled={loading}>
{t('Retour')}
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canCreate}>
<PzBusy busy={loading} busyLabel={form.start === 'import' ? t('Import en cours') : t("Création de l'arbre")}>
{form.start === 'import' ? t('Importer mon arbre') : t('Créer mon arbre')}
</PzBusy>
</button>
</div>
</form>
)}
</div>
</div>
)
}
export default TreeCreationWizard