Files
parenthese/frontend/src/App.jsx
T
IliesAllaliandClaude Haiku 5.5 6faaefebee Fiche : fermer en glissant vers le bas sur téléphone
La feuille de la fiche (PersonCard) se ferme en glissant sa poignée ou son en-tête vers le bas, comme le menu de la barre. Le geste est factorisé : logique pure dans utils/sheetDrag.js, hook dans hooks/useSheetDrag.js, utilisé aussi par NavMenu (poignée, seuil de 70 px inchangé).

- Le geste part seulement de la poignée et de l'en-tête. Jamais d'un champ de saisie, d'un libellé ni pendant une sélection de texte. Le contenu des onglets défile comme avant.
- La feuille suit le doigt (transform), ne monte jamais au-delà de sa position de repos.
- Fermeture au-delà d'un tiers de la hauteur, ou sur un lâcher rapide vers le bas. Sinon retour court.
- La fermeture passe par le même chemin que la croix (enregistrement automatique compris). Si l'enregistrement est refusé, la fiche revient et affiche son erreur.
- Mode feuille seulement (PHONE_QUERY et largeur de la feuille CSS). Sur ordinateur, rien ne change.
- prefers-reduced-motion respecté.

Tests : logique du geste (zones, direction, seuil, vitesse) en vitest. Recette Playwright iPhone 13 émulé, gestes tactiles CDP : fermeture par la poignée, retour après un petit glisser, lâcher rapide, défilement du contenu sans fermer, pas de geste depuis un champ, enregistrement puis fermeture, ordinateur inchangé, croix.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 11:06:35 +02:00

3303 lines
130 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { buildMediaUrl } from './utils/mediaToken.js'
import Galaxy from './components/Galaxy'
import PersonCard from './components/PersonCard.jsx'
import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx'
import { HELP_URL } from './utils/siteLinks.js'
import { firstNameLabel } from './utils/personName.js'
import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
import AdminPanel from './components/AdminPanel'
import AccessGate from './components/AccessGate'
import EntryChoice from './components/EntryChoice'
import AccountDashboard from './components/AccountDashboard'
import AccountPanel from './components/AccountPanel'
import TreeCreationWizard from './components/TreeCreationWizard'
import PhotoStartPanel from './components/PhotoStartPanel.jsx'
import ContributionPanel from './components/ContributionPanel'
import EditModeOverlay from './components/EditSessionBar.jsx'
import EditConfirmModal from './components/EditConfirmModal'
import DraftRestoreBanner from './components/DraftRestoreBanner'
import AddPersonPanel from './components/AddPersonPanel'
import ContributionSessionModal from './components/ContributionSessionModal'
import { ContextualNavbar } from './components/navbar'
import ZoomControl from './components/ZoomControl'
import { ExpandableSearch } from './components/search'
import { t, dateLocale } from './i18n/index.js'
import {
createParentChildLink,
updateParentChildLink,
deleteParentChildLink,
deletePerson,
deleteUnion,
createPerson,
createTree,
createUnion,
batchAnnotations,
deletePersonAvatar,
getTree,
importGedcom,
linkTreeAccess,
updateTree,
rotateTreePasswords,
fetchSharePassword,
rememberSharePassword,
resolveTreeBySlug,
saveAvatarCrop,
uploadPersonAvatar,
updatePerson,
uploadAnnotationPhoto,
reorderSiblings,
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { useAuth } from './hooks/useAuth'
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
import { useMediaManager } from './hooks/useMediaManager'
import { useContributions } from './hooks/useContributions'
import { useUserRole } from './hooks/useUserRole'
import { usePersonSearch } from './hooks/usePersonSearch'
import { useEditMode } from './hooks/useEditMode'
import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
import { readGedcomFile } from './utils/gedcomText'
import { readGedcomBundle, zipErrorMessage } from './utils/gedcomBundle.js'
import { materializeFile } from './utils/zipReader.js'
import { loadMediaRefs, loadPhotoRefs, pickFirstFaces, prepareImageForUpload, saveMediaRefs, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages'
import { getSiblingParentLinks } from './utils/familyLinks'
import { getUploadMimeType, inferMediaTypeFromMimeType, validateMediaFile } from './utils/mediaUpload'
import {
getSource,
identifyUser,
resetUser,
toInteractionMediaType,
toJourneyRole,
toOpaqueTreeId,
trackAppEvent,
} from './utils/analytics'
import './App.css'
import './styles/pz.css'
import { setBooting } from './bootSignal.js'
import { saveContributorName } from './utils/contributorName'
function yearToIsoDate(value) {
if (value === null || value === undefined || value === '') {
return null
}
const raw = String(value).trim()
// If user provided a full date YYYY-MM-DD, keep it.
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return raw
}
const year = Number.parseInt(raw.slice(0, 4), 10)
if (!Number.isFinite(year)) {
return null
}
return `${String(year).padStart(4, '0')}-01-01`
}
function extractYearFromIsoDate(value) {
if (!value || typeof value !== 'string') return null
const year = Number.parseInt(value.slice(0, 4), 10)
return Number.isFinite(year) ? year : null
}
function normalizeSlug(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function generateSecurePassword() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replace(/-/g, '')
}
// crypto.randomUUID n'est exposé qu'en contexte sécurisé (HTTPS ou localhost) :
// sur du HTTP simple, getRandomValues reste disponible et donne le même UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function getAccountSelfPersonStorageKey(userEmail, treeId) {
return `account_self_person_${userEmail || 'unknown'}_${treeId || 'none'}`
}
function buildContributionChangesFromDraft(draft = {}) {
const changes = []
const relationTypeToCanonical = (relation) => {
if (!relation || !relation.sourcePersonId || !relation.targetPersonId) {
return null
}
const sourcePersonId = String(relation.sourcePersonId)
const targetPersonId = String(relation.targetPersonId)
const relationType = String(relation.relationType || '')
if (relationType === 'spouse') {
const ordered = [sourcePersonId, targetPersonId].sort()
return {
relationType: 'spouse',
sourcePersonId,
targetPersonId,
key: `spouse:${ordered[0]}:${ordered[1]}`,
}
}
if (relationType === 'parent' || relationType === 'child') {
const parentPersonId = relationType === 'parent' ? targetPersonId : sourcePersonId
const childPersonId = relationType === 'parent' ? sourcePersonId : targetPersonId
return {
relationType: 'parent_child_link',
parentPersonId,
childPersonId,
key: `parent_child:${parentPersonId}:${childPersonId}`,
}
}
return null
}
const resolveRemovedRelationEntity = (canonicalRelation) => {
if (!canonicalRelation) {
return null
}
if (canonicalRelation.relationType === 'spouse') {
const union = unions.find((candidate) => (
(String(candidate.partner1Id) === canonicalRelation.sourcePersonId
&& String(candidate.partner2Id) === canonicalRelation.targetPersonId)
|| (String(candidate.partner1Id) === canonicalRelation.targetPersonId
&& String(candidate.partner2Id) === canonicalRelation.sourcePersonId)
))
if (!union) {
return null
}
return {
entityType: 'union',
action: 'delete',
entityId: String(union.id),
}
}
let link = filiations.find((candidate) => (
String(candidate.childId) === canonicalRelation.childPersonId
&& String(candidate.parentId) === canonicalRelation.parentPersonId
))
if (!link) {
const relatedUnionIds = new Set(
unions
.filter((candidate) => (
String(candidate.partner1Id) === canonicalRelation.parentPersonId
|| String(candidate.partner2Id) === canonicalRelation.parentPersonId
))
.map((candidate) => String(candidate.id)),
)
link = filiations.find((candidate) => (
String(candidate.childId) === canonicalRelation.childPersonId
&& candidate.unionId
&& relatedUnionIds.has(String(candidate.unionId))
))
}
if (!link) {
return null
}
return {
entityType: 'parent_child_link',
action: 'delete',
entityId: String(link.id),
}
}
const addedPersons = Array.isArray(draft.addedPersons) ? draft.addedPersons : []
for (const person of addedPersons) {
if (!person || typeof person !== 'object') continue
// Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant
// Nom de famille facultatif : seule une personne sans aucun nom est ignorée
if (!person.firstName && !person.lastName) continue
// Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens
const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null
changes.push({
entityType: 'person',
action: 'create',
after: {
...(ref ? { ref } : {}),
firstName: String(person.firstName).trim(),
lastName: String(person.lastName).trim(),
birthName: person.birthName ? String(person.birthName).trim() : null,
nickname: person.nickname ? String(person.nickname).trim() : null,
birthDate: person.birthDate || null,
deathDate: person.deathDate || null,
isDeceased: !person.deathDate && Boolean(person.isDeceased),
},
})
if (!ref) continue
const linkedParentIds = new Set()
for (const rel of Array.isArray(person.relations) ? person.relations : []) {
if (!rel?.personId) continue
const relatedId = String(rel.personId)
if (rel.type === 'child') {
linkedParentIds.add(relatedId)
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
} else if (rel.type === 'parent') {
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
} else if (rel.type === 'spouse') {
changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } })
}
}
for (const link of Array.isArray(person.siblingParentLinks) ? person.siblingParentLinks : []) {
if (!link?.parentId || linkedParentIds.has(String(link.parentId))) continue
linkedParentIds.add(String(link.parentId))
changes.push({
entityType: 'parent_child_link',
action: 'create',
after: { parentPersonId: String(link.parentId), childPersonId: ref, viaUnionId: link.unionId || null, parentageType: 'biologique' },
})
}
}
// Lien avec ses parents basculé (naissance / adoption) sur un lien existant
const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {}
for (const [childId, parentageType] of Object.entries(parentageChanges)) {
for (const filiation of filiations) {
if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue
if ((filiation.parentageType || 'biologique') === parentageType) continue
changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } })
}
}
const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object'
? draft.modifiedPersons
: {}
for (const [personId, rawChanges] of Object.entries(modifiedPersons)) {
const source = rawChanges && typeof rawChanges === 'object' ? rawChanges : {}
const after = {}
if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || ''
if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || ''
if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear)
if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear)
if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null
if (Object.prototype.hasOwnProperty.call(source, 'isAlive')) {
if (source.isAlive === true) after.deathDate = null
after.isDeceased = source.isAlive === false
}
if (Object.keys(after).length > 0) {
changes.push({
entityType: 'person',
action: 'update',
entityId: personId,
after,
})
}
}
const deletedPersons = Array.isArray(draft.deletedPersons) ? draft.deletedPersons : []
for (const personId of deletedPersons) {
if (!personId) continue
changes.push({
entityType: 'person',
action: 'delete',
entityId: String(personId),
})
}
const addedRelations = Array.isArray(draft.addedRelations) ? draft.addedRelations : []
const removedRelations = Array.isArray(draft.removedRelations) ? draft.removedRelations : []
const canonicalAddedRelations = []
const canonicalRemovedRelations = []
const addedRelationKeys = new Set()
const removedRelationKeys = new Set()
for (const relation of addedRelations) {
const canonicalRelation = relationTypeToCanonical(relation)
if (!canonicalRelation || addedRelationKeys.has(canonicalRelation.key)) continue
canonicalAddedRelations.push(canonicalRelation)
addedRelationKeys.add(canonicalRelation.key)
}
for (const relation of removedRelations) {
const canonicalRelation = relationTypeToCanonical(relation)
if (!canonicalRelation || removedRelationKeys.has(canonicalRelation.key)) continue
canonicalRemovedRelations.push(canonicalRelation)
removedRelationKeys.add(canonicalRelation.key)
}
const canceledRelationKeys = new Set(
[...addedRelationKeys].filter((key) => removedRelationKeys.has(key)),
)
for (const canonicalRelation of canonicalAddedRelations) {
if (canceledRelationKeys.has(canonicalRelation.key)) continue
if (canonicalRelation.relationType === 'spouse') {
changes.push({
entityType: 'union',
action: 'create',
after: {
partner1PersonId: canonicalRelation.sourcePersonId,
partner2PersonId: canonicalRelation.targetPersonId,
unionType: 'mariage',
},
})
} else if (canonicalRelation.relationType === 'parent_child_link') {
changes.push({
entityType: 'parent_child_link',
action: 'create',
after: {
parentPersonId: canonicalRelation.parentPersonId,
childPersonId: canonicalRelation.childPersonId,
parentageType: 'biologique',
},
})
}
}
for (const relation of canonicalRemovedRelations) {
if (canceledRelationKeys.has(relation.key)) continue
const change = resolveRemovedRelationEntity(relation)
if (!change) continue
changes.push(change)
}
// Annotations (stickers, dessins, textes, photos canvas)
const annData = Array.isArray(draft.annotationsData) ? draft.annotationsData : []
const addedAnnIds = new Set(Array.isArray(draft.addedAnnotations) ? draft.addedAnnotations : [])
const modifiedAnnIds = new Set(Array.isArray(draft.modifiedAnnotations) ? draft.modifiedAnnotations : [])
const deletedAnnIds = Array.isArray(draft.deletedAnnotations) ? draft.deletedAnnotations : []
for (const ann of annData) {
const isAdded = addedAnnIds.has(ann.id)
const isModified = modifiedAnnIds.has(ann.id)
if (!isAdded && !isModified) continue
const action = isAdded ? 'create' : 'update'
const change = {
entityType: 'annotation',
action,
after: {
type: ann.type,
x: ann.x,
y: ann.y,
content: ann.content,
style: ann.style || {},
zIndex: ann.zIndex || 0,
},
}
if (action === 'update') change.entityId = String(ann.id)
changes.push(change)
}
for (const annId of deletedAnnIds) {
changes.push({ entityType: 'annotation', action: 'delete', entityId: String(annId) })
}
return changes
}
function buildContribChangesList(draft = {}) {
const items = []
const addedPersonEntries = Array.isArray(draft.addedPersons) ? draft.addedPersons : []
const addedPersonIds = addedPersonEntries.filter((entry) => typeof entry !== 'object').map(String)
for (const entry of addedPersonEntries) {
const d = entry && typeof entry === 'object' ? entry : (draft.modifiedPersons?.[String(entry)] || {})
const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || t('Nouvelle personne')
items.push({ entityType: 'person', action: 'create', label: name })
}
for (const [id, d] of Object.entries(draft.modifiedPersons || {})) {
if (addedPersonIds.includes(String(id))) continue
const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || t('Personne')
items.push({ entityType: 'person', action: 'update', label: name })
}
for (let i = 0; i < (draft.deletedPersons || []).length; i++) {
items.push({ entityType: 'person', action: 'delete', label: t('Personne') })
}
const annData = Array.isArray(draft.annotationsData) ? draft.annotationsData : []
const addedAnnIds = new Set(Array.isArray(draft.addedAnnotations) ? draft.addedAnnotations : [])
const modifiedAnnIds = new Set(Array.isArray(draft.modifiedAnnotations) ? draft.modifiedAnnotations : [])
const deletedAnnCount = (Array.isArray(draft.deletedAnnotations) ? draft.deletedAnnotations : []).length
const annTypeLabel = (type) => ({ sticker: t('Sticker'), drawing: t('Dessin'), text: t('Texte'), photo: t('Photo') }[type] || t('Annotation'))
for (const ann of annData) {
if (addedAnnIds.has(ann.id)) items.push({ entityType: 'annotation', action: 'create', label: annTypeLabel(ann.type) })
else if (modifiedAnnIds.has(ann.id)) items.push({ entityType: 'annotation', action: 'update', label: annTypeLabel(ann.type) })
}
for (let i = 0; i < deletedAnnCount; i++) {
items.push({ entityType: 'annotation', action: 'delete', label: t('Annotation') })
}
for (let i = 0; i < (draft.addedRelations || []).length; i++) {
items.push({ entityType: 'relation', action: 'create', label: t('Relation') })
}
for (let i = 0; i < (draft.removedRelations || []).length; i++) {
items.push({ entityType: 'relation', action: 'delete', label: t('Relation') })
}
return items
}
function App() {
const auth = useAuth()
const tree = useTreeAccess()
useEffect(() => { setBooting(tree.bootState === 'loading') }, [tree.bootState])
// Arbre partagé d'où l'on vient quand on ouvre l'écran compte : on y revient après connexion/création
const pendingSharedTreeRef = useRef(null)
// Son nom, affiché par l'écran compte : un ref lu au rendu ne redessine rien quand il change
const [pendingSharedTreeName, setPendingSharedTreeName] = useState('')
// Écran de compte ouvert depuis un arbre affiché : la croix y ramène
const [accountFromTree, setAccountFromTree] = useState(false)
const refreshTreeAndKeepSelection = useCallback(async (personId, options = {}) => {
await tree.refreshCurrentTreeGraph({
relayout: options.relayout !== false,
avatarCacheBust: options.avatarCacheBust,
})
if (personId !== null && personId !== undefined) {
const refreshedPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || null
tree.setSelectedPerson(refreshedPerson)
}
}, [tree.refreshCurrentTreeGraph, tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const media = useMediaManager({
canEditCurrentTree: tree.canEditCurrentTree,
canSubmitContribution: tree.canSubmitContribution,
selectedPerson: tree.selectedPerson,
treeContext: tree.treeContext,
refreshCurrentTreeGraph: tree.refreshCurrentTreeGraph,
refreshTreeAndKeepSelection,
})
const contrib = useContributions({
canEditCurrentTree: tree.canEditCurrentTree,
canSubmitContribution: tree.canSubmitContribution,
treeContext: tree.treeContext,
refreshCurrentTreeGraph: tree.refreshCurrentTreeGraph,
})
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [], bundledFiles: [] })
const photoStartDirtyRef = useRef(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [showDraftBanner, setShowDraftBanner] = useState(false)
const [accountPanelVisible, setAccountPanelVisible] = useState(false)
const [linkedSelfPersonId, setLinkedSelfPersonId] = useState('')
const [relationFeedback, setRelationFeedback] = useState({
loading: false,
success: '',
error: '',
})
const [avatarActionState, setAvatarActionState] = useState({
loading: false,
error: '',
})
const [treeNotFound, setTreeNotFound] = useState(false)
const [accountEntryMode, setAccountEntryMode] = useState('login')
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
const [treeImportError, setTreeImportError] = useState('')
const [contribInviteState, setContribInviteState] = useState({
shareUrl: '',
knownPasswords: {
share: '',
},
loading: false,
error: '',
message: '',
})
const buildEmptyTreeContext = useCallback((overrides = {}) => ({
treeId: '',
treeName: '',
treeDescription: '',
treeOwnerName: '',
treeOwnerEmail: '',
role: '',
accessMode: '',
rootPersonId: null,
accessToken: '',
...overrides,
}), [])
// Déterminer le rôle et permissions de l'utilisateur
const userRole = useUserRole({
treeContext: tree.treeContext,
authenticated: auth.authenticated,
})
// Famille arrivée par le lien (mot de passe de partage, avec ou sans compte)
const isFamilyVisitor = userRole.navbarType === 'contributor_anon' || userRole.navbarType === 'contributor_auth'
const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false)
const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false)
const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null)
// Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries
const [draftNotice, setDraftNotice] = useState('')
// Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef)
const personSaveOnLeaveRef = useRef(null)
useEffect(() => {
if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) {
setFamilyWelcomeVisible(true)
}
}, [isFamilyVisitor, tree.bootState, tree.treeContext.treeId])
// La personne choisie dans « Pour qui ? » est sélectionnée : ouvrir ses souvenirs
useEffect(() => {
if (pendingMediaPersonId == null) return
if (String(tree.selectedPerson?.id) !== String(pendingMediaPersonId)) return
setPendingMediaPersonId(null)
media.handleOpenMediaManager()
}, [pendingMediaPersonId, tree.selectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const closeFamilyWelcome = useCallback(() => {
markFamilyWelcomeSeen(tree.treeContext.treeId)
setFamilyWelcomeVisible(false)
}, [tree.treeContext.treeId])
const accountUser = auth.userAuth.user || null
const linkedSelfPerson = useMemo(() => (
persons.find((candidate) => String(candidate.id) === String(linkedSelfPersonId)) || null
), [linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId])
// Les visages à demander en premier : la famille proche de « Moi » (ou de la personne de départ),
// sinon les générations les plus récentes
const computeFirstFaces = useCallback(() => pickFirstFaces({
persons,
selfId: linkedSelfPersonId || tree.treeContext.rootPersonId || null,
getParents,
getChildren,
getPartners: (id) => getPersonUnions(id)
.map((union) => (union.partner1Id === id ? union.partner2Id : union.partner1Id))
.map((partnerId) => persons.find((person) => person.id === partnerId))
.filter(Boolean),
}), [linkedSelfPersonId, tree.treeContext.rootPersonId])
// bundledFiles : les photos et actes d'un zip importé, rapprochés tout de suite sans demander de dossier
const openPhotoStart = useCallback(({ personsImported = null, refs = null, mediaRefs = null, treeId = null, bundledFiles = [] } = {}) => {
photoStartDirtyRef.current = false
setPhotoStart({
visible: true,
personsImported,
refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId),
mediaRefs: mediaRefs ?? loadMediaRefs(treeId || tree.treeContext.treeId),
faces: computeFirstFaces(),
bundledFiles,
})
}, [computeFirstFaces, tree.treeContext.treeId])
const accountDisplayName = useMemo(() => {
if (linkedSelfPerson) {
const fullName = `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim()
if (fullName) {
return fullName
}
}
const firstName = accountUser?.firstName || accountUser?.name || ''
if (firstName && String(firstName).trim()) {
return String(firstName).trim()
}
const email = accountUser?.email || ''
if (!email) {
return t('Compte')
}
return String(email).split('@')[0] || t('Compte')
}, [accountUser?.email, accountUser?.firstName, accountUser?.name, linkedSelfPerson])
const accountEmail = accountUser?.email || ''
const accountAvatarPhoto = linkedSelfPerson?.photo || ''
const activeAccountTreeName = useMemo(() => {
const activeTree = auth.accountTrees.find(
(candidate) => String(candidate.id) === String(tree.treeContext.treeId),
)
return activeTree?.name || tree.treeContext.treeName || ''
}, [auth.accountTrees, tree.treeContext.treeId, tree.treeContext.treeName])
const isDemoMode = tree.treeContext.accessMode === 'demo'
const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode
// Recherche de personnes avec scoring
const search = usePersonSearch(persons)
// Mode édition avec auto-save
const editMode = useEditMode({
treeId: tree.treeContext.treeId,
canEdit: canEditInCurrentMode,
})
const localEntityCounterRef = useRef(0)
const makeLocalId = useCallback((prefix) => {
localEntityCounterRef.current += 1
return `${prefix}-${Date.now().toString(36)}-${localEntityCounterRef.current}`
}, [])
const shareSessionStartedAtRef = useRef(0)
const shareSessionTimeSentRef = useRef(false)
const journeyRole = useMemo(
() => toJourneyRole(tree.treeContext.role),
[tree.treeContext.role],
)
const opaqueTreeId = useMemo(
() => toOpaqueTreeId(tree.treeContext.treeId),
[tree.treeContext.treeId],
)
const isTrackableShareSession = Boolean(
tree.bootState === 'ready'
&& tree.treeContext.accessMode === 'share'
&& tree.treeContext.treeId
&& journeyRole,
)
const captureTreeTimeSpent = useCallback(() => {
if (!isTrackableShareSession || shareSessionTimeSentRef.current) return
if (!shareSessionStartedAtRef.current) return
const elapsedSeconds = Math.max(1, Math.round((Date.now() - shareSessionStartedAtRef.current) / 1000))
trackAppEvent('tree_time_spent', {
seconds: elapsedSeconds,
role: journeyRole,
tree_id: opaqueTreeId,
})
shareSessionTimeSentRef.current = true
}, [isTrackableShareSession, journeyRole, opaqueTreeId])
useEffect(() => {
if (!isTrackableShareSession) {
shareSessionStartedAtRef.current = 0
shareSessionTimeSentRef.current = false
return
}
shareSessionStartedAtRef.current = Date.now()
shareSessionTimeSentRef.current = false
trackAppEvent('tree_viewed', {
role: journeyRole,
tree_id: opaqueTreeId,
})
const fiveMinutesTimeout = window.setTimeout(() => {
captureTreeTimeSpent()
}, 5 * 60 * 1000)
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
captureTreeTimeSpent()
}
}
const handlePageHide = () => {
captureTreeTimeSpent()
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pagehide', handlePageHide)
return () => {
window.clearTimeout(fiveMinutesTimeout)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pagehide', handlePageHide)
captureTreeTimeSpent()
}
}, [captureTreeTimeSpent, isTrackableShareSession, journeyRole, opaqueTreeId])
const trackTreeInteraction = useCallback((mediaType) => {
if (!isTrackableShareSession) return
trackAppEvent('tree_interaction', {
role: journeyRole,
media_type: mediaType,
tree_id: opaqueTreeId,
})
}, [isTrackableShareSession, journeyRole, opaqueTreeId])
// Identifiant technique du compte, jamais l'adresse email (engagement de la page Données et vie privée).
// Remise à zéro seulement à la déconnexion : la faire à chaque ouverture anonyme effaçait le visiteur
// de la landing (cookie partagé sur .parenthese.io, démo en iframe) et coupait le funnel.
const wasAuthenticatedRef = useRef(false)
useEffect(() => {
if (auth.authenticated && auth.userAuth.user?.id) {
wasAuthenticatedRef.current = true
identifyUser(auth.userAuth.user.id)
return
}
if (wasAuthenticatedRef.current) {
wasAuthenticatedRef.current = false
resetUser()
}
}, [auth.authenticated, auth.userAuth.user?.id])
// Démo : une ouverture par chargement, puis une interaction de chaque type (sans aucun contenu de l'arbre).
// Ne compte que les visiteurs sans compte, la démo sert aussi de fond après une inscription.
const demoOpenedRef = useRef(false)
const demoInteractionsRef = useRef(new Set())
useEffect(() => {
if (!isDemoMode || tree.bootState !== 'ready' || auth.authenticated || demoOpenedRef.current) return
demoOpenedRef.current = true
const query = new URLSearchParams(window.location.search)
trackAppEvent('demo_opened', { embed: query.has('embed'), source: getSource() })
}, [isDemoMode, tree.bootState, auth.authenticated])
const trackDemoInteraction = useCallback((type) => {
if (!isDemoMode || auth.authenticated || demoInteractionsRef.current.has(type)) return
demoInteractionsRef.current.add(type)
trackAppEvent('demo_interaction', { type, embed: new URLSearchParams(window.location.search).has('embed') })
}, [isDemoMode, auth.authenticated])
useEffect(() => {
const treeId = tree.treeContext.treeId
if (!treeId || !accountEmail) {
setLinkedSelfPersonId('')
return
}
try {
const saved = localStorage.getItem(getAccountSelfPersonStorageKey(accountEmail, treeId)) || ''
setLinkedSelfPersonId(saved)
} catch {
setLinkedSelfPersonId('')
}
}, [accountEmail, tree.treeContext.treeId])
useEffect(() => {
if (!linkedSelfPersonId || !tree.treeContext.treeId || !accountEmail) {
return
}
const exists = persons.some((candidate) => String(candidate.id) === String(linkedSelfPersonId))
if (!exists) {
setLinkedSelfPersonId('')
try {
localStorage.removeItem(getAccountSelfPersonStorageKey(accountEmail, tree.treeContext.treeId))
} catch {
// Ignore storage failures
}
}
}, [accountEmail, linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId])
// Annotations canvas (dessin, stickers, texte)
const annot = useAnnotations({
editModeActive: editMode.isActive,
updateDraft: editMode.updateDraft,
})
// Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un
// souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte
// avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas.
const leaveSelectedPerson = useCallback(async () => {
const current = tree.selectedPerson
const saveOnLeave = personSaveOnLeaveRef.current
if (!current || !saveOnLeave || isDemoMode) return true
const outcome = await saveOnLeave()
if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) }))
return outcome !== 'blocked'
}, [tree.selectedPerson, isDemoMode])
const chooseSelectedPerson = useCallback(async (next) => {
if (!(await leaveSelectedPerson())) return false
tree.setSelectedPerson(next)
return true
}, [leaveSelectedPerson, tree.setSelectedPerson])
const deactivateEditMode = useCallback(async () => {
if (!(await leaveSelectedPerson())) return
editMode.deactivate()
}, [leaveSelectedPerson, editMode.deactivate])
const handlePersonSelect = useCallback(async (personLike) => {
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
if (personId === null || personId === undefined) {
await chooseSelectedPerson(null)
return
}
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
if (!(await leaveSelectedPerson())) return
// Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ;
// sa saisie reste en mémoire et reprend quand on rouvre « Ajouter »
setAddPersonPanelVisible(false)
tree.setSelectedPerson(freshPerson)
trackTreeInteraction('text')
trackDemoInteraction('person')
}, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
const handleMediaSelectFresh = useCallback(async (media, personLike) => {
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
const freshPerson = personId !== null && personId !== undefined
? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
: personLike || null
if (!(await leaveSelectedPerson())) return
tree.handleMediaSelect(media, freshPerson)
trackTreeInteraction(toInteractionMediaType(media?.type))
trackDemoInteraction('media')
}, [tree.handleMediaSelect, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
// Ref that Galaxy fills with a pointer to its transformRef (for computing view center)
const galaxyTransformReadRef = useRef(null)
// Ref exposant l'API zoom de Galaxy au ZoomControl
const galaxyZoomApiRef = useRef(null)
// Called by AnnotationToolbar when user picks a photo file for canvas annotation
const handleAnnotationPhotoFile = useCallback(async (file) => {
const treeId = tree.treeContext.treeId
const token = tree.treeContext.accessToken || auth.userAuth.token
if (!treeId || !token || !file) return
try {
const dataBase64 = await fileToBase64(file)
const result = await uploadAnnotationPhoto(
treeId,
{
fileName: file.name || 'photo.jpg',
mimeType: file.type || 'image/jpeg',
sizeBytes: file.size,
dataBase64,
},
token,
)
// Compute world-space view center so the photo appears in the middle of the screen
const view = galaxyTransformReadRef.current?.current || { x: 0, y: 0, scale: 1 }
const worldX = (window.innerWidth / 2 - view.x) / view.scale
const worldY = (window.innerHeight / 2 - view.y) / view.scale
// Get natural dimensions from the file
const img = new Image()
img.onload = () => {
// Adresse d'affichage avec le jeton médias ; le serveur n'enregistre que photoPath
const fullUrl = buildMediaUrl(result.photoPath, import.meta.env.VITE_API_BASE_URL || '/api')
annot.placePhoto(worldX, worldY, result.photoPath, fullUrl, img.naturalWidth, img.naturalHeight)
URL.revokeObjectURL(img.src)
}
img.onerror = () => URL.revokeObjectURL(img.src)
img.src = URL.createObjectURL(file)
} catch {
// silent — user can retry
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
// Afficher le banner de restauration si un draft existe au mount
useEffect(() => {
if (editMode.hasDraft && !editMode.isActive) {
setShowDraftBanner(true)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Toggle édition avec confirmation si draft en cours
const handleEditToggle = useCallback(() => {
if (editMode.isActive && editMode.hasDraft) {
setShowConfirmModal(true)
} else if (editMode.isActive) {
deactivateEditMode()
} else {
trackDemoInteraction('edit')
editMode.toggle()
}
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
// Actions de la modal de confirmation
const handleConfirmContinue = useCallback(() => {
setShowConfirmModal(false)
}, [])
const handleConfirmSaveAndQuit = useCallback(async () => {
const left = await leaveSelectedPerson()
setShowConfirmModal(false)
if (!left) return
editMode.saveDraft()
editMode.deactivate()
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
const handleConfirmDiscard = useCallback(() => {
editMode.clearDraft()
editMode.deactivate()
setShowConfirmModal(false)
}, [editMode.clearDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
// Actions du banner de restauration
const handleDraftRestore = useCallback(() => {
editMode.activate()
setShowDraftBanner(false)
}, [editMode.activate]) // eslint-disable-line react-hooks/exhaustive-deps
const handleDraftDiscard = useCallback(() => {
editMode.clearDraft()
setShowDraftBanner(false)
}, [editMode.clearDraft]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!relationFeedback.success) return undefined
const timeout = setTimeout(() => {
setRelationFeedback((current) => ({ ...current, success: '' }))
}, 2500)
return () => clearTimeout(timeout)
}, [relationFeedback.success])
useEffect(() => {
setAvatarActionState({ loading: false, error: '' })
}, [tree.selectedPerson?.id])
// Panneau ajout personne
const [addPersonPanelVisible, setAddPersonPanelVisible] = useState(false)
// Quitter le mode édition ferme le panneau d'ajout (sinon il réapparaît au retour, vide)
useEffect(() => {
if (!editMode.isActive) setAddPersonPanelVisible(false)
}, [editMode.isActive])
const [addPersonLoading, setAddPersonLoading] = useState(false)
const [addPersonError, setAddPersonError] = useState('')
const handleOpenAddPerson = useCallback(() => {
setAddPersonError('')
// Le panneau d'ajout vit dans le mode édition : la famille y entre sans passer par le crayon
if (!editMode.isActive) editMode.activate()
setAddPersonPanelVisible(true)
}, [editMode.isActive, editMode.activate]) // eslint-disable-line react-hooks/exhaustive-deps
// « Ajouter un souvenir » : choisir la personne, puis ouvrir ses souvenirs
const handleOpenSouvenirPicker = useCallback(() => {
markFamilyWelcomeSeen(tree.treeContext.treeId)
setFamilyWelcomeVisible(false)
setSouvenirPickerVisible(true)
}, [tree.treeContext.treeId])
const handleSouvenirPick = useCallback(async (person) => {
if (!(await chooseSelectedPerson(person))) return
setSouvenirPickerVisible(false)
setPendingMediaPersonId(person.id)
}, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSouvenirPickerAddPerson = useCallback(() => {
setSouvenirPickerVisible(false)
handleOpenAddPerson()
}, [handleOpenAddPerson])
const handleCloseAddPerson = useCallback(() => {
setAddPersonPanelVisible(false)
setAddPersonError('')
}, [])
const handleAddPersonSubmit = useCallback(async (formData) => {
if (!tree.treeContext.treeId && !isDemoMode) return
// La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace
if (!(await leaveSelectedPerson())) return
setAddPersonLoading(true)
setAddPersonError('')
try {
// Un frère ou une sœur reçoit les mêmes parents que la personne choisie :
// sans parent connu, on s'arrête avant de créer quoi que ce soit.
const siblingParentLinks = []
if (Array.isArray(formData.relations)) {
for (const rel of formData.relations) {
if (rel.type !== 'sibling') continue
const links = getSiblingParentLinks(rel.personId, filiations, unions)
if (links.length === 0) {
const sibling = persons.find((person) => String(person.id) === String(rel.personId))
const siblingName = [sibling?.firstName, sibling?.lastName].filter(Boolean).join(' ')
setAddPersonError(siblingName
? t("Ajoutez d'abord un parent à {name} pour pouvoir lui rattacher un frère ou une sœur.", { name: siblingName })
: t("Ajoutez d'abord un parent à cette personne pour pouvoir lui rattacher un frère ou une sœur."))
return
}
siblingParentLinks.push(...links)
}
}
if (!tree.canEditCurrentTree) {
if (isDemoMode) {
const personId = makeLocalId('local-person')
const birthYear = extractYearFromIsoDate(formData.birthDate)
const deathYear = extractYearFromIsoDate(formData.deathDate)
const newPerson = {
id: personId,
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
deathYear,
isAlive: !deathYear,
photo: null,
frameType: 'round',
}
persons.push(newPerson)
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
const existsUnion = unions.some((union) => (
(String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId))
|| (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: rel.personId,
partner2Id: personId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
})
}
} else if (rel.type === 'parent') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
}
}
for (const link of siblingParentLinks) {
const alreadyLinked = filiations.some((filiation) => (
String(filiation.childId) === String(personId)
&& (link.unionId
? String(filiation.unionId) === link.unionId
: String(filiation.parentId) === link.parentId)
))
if (alreadyLinked) continue
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: link.unionId ? null : link.parentId,
unionId: link.unionId,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
}
tree.bumpGraphRevision()
tree.setSelectedPerson({ ...newPerson })
}
const draft = editMode.restoreDraft()
const addedPersons = [
...(draft.addedPersons || []),
{
ref: `tmp:${makeLocalId('personne')}`,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
editMode.updateDraft({ addedPersons })
// La personne n'apparaît dans l'arbre qu'après envoi : on passe tout de suite à l'envoi
// (prénom, un mot), la famille n'a pas à chercher le bouton
setAddPersonPanelVisible(false)
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
setContribSubmitError('')
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
setShowContribModal(true)
return
}
if (!auth.userAuth.token) return
const newPerson = await createPerson(
tree.treeContext.treeId,
{
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
},
auth.userAuth.token
)
// Créer les liens de relation
const linkedParentIds = new Set()
for (const rel of formData.relations) {
if (rel.type === 'child') {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 },
auth.userAuth.token
)
}
}
for (const link of siblingParentLinks) {
if (linkedParentIds.has(link.parentId)) continue
linkedParentIds.add(link.parentId)
await createParentChildLink(
tree.treeContext.treeId,
{
parentPersonId: link.parentId,
childPersonId: newPerson.id,
viaUnionId: link.unionId,
parentageType: 'biologique',
displayOrder: 1,
},
auth.userAuth.token
)
}
// Mettre à jour le draft avec la nouvelle personne
editMode.updateDraft({
addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id],
})
await tree.refreshCurrentTreeGraph()
} catch (err) {
setAddPersonError(err.message || t("Erreur lors de l'ajout de la personne"))
} finally {
setAddPersonLoading(false)
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.treeId, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
// Modal session contribution (contributeurs)
const [showContribModal, setShowContribModal] = useState(false)
const [contribChanges, setContribChanges] = useState([])
const [contribSubmitLoading, setContribSubmitLoading] = useState(false)
const [contribSubmitError, setContribSubmitError] = useState('')
const [contribSuccess, setContribSuccess] = useState(false)
// Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite.
// L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit.
const [siblingOrderRevision, setSiblingOrderRevision] = useState(0)
const handleReorderSiblings = useCallback(async (order) => {
const previous = new Map()
order.forEach((entry, index) => {
for (const filiation of filiations) {
if (!entry.linkIds.includes(String(filiation.id))) continue
previous.set(filiation, filiation.displayOrder)
filiation.displayOrder = index + 1
}
})
setSiblingOrderRevision((current) => current + 1)
if (isDemoMode) return
try {
await reorderSiblings(tree.treeContext.treeId, order, tree.treeContext.accessToken || auth.userAuth.token)
} catch (error) {
for (const [filiation, value] of previous) filiation.displayOrder = value
setSiblingOrderRevision((current) => current + 1)
setDraftNotice(t("L'ordre n'a pas pu être enregistré. Réessayez."))
throw error
}
}, [isDemoMode, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
const canReorderSiblings = editMode.isActive && (tree.canEditCurrentTree || isDemoMode)
const [siblingPlace, setSiblingPlace] = useState(null)
useEffect(() => {
const personId = tree.selectedPerson?.id
setSiblingPlace(personId != null && canReorderSiblings ? galaxyZoomApiRef.current?.getSiblingPlace?.(personId) ?? null : null)
}, [tree.selectedPerson?.id, canReorderSiblings, siblingOrderRevision, tree.graphRevision])
useEffect(() => {
if (!draftNotice) return undefined
const timer = setTimeout(() => setDraftNotice(''), 4500)
return () => clearTimeout(timer)
}, [draftNotice])
// Soumission des modifications
const handleEditSubmit = useCallback(async () => {
// Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi
if (!(await leaveSelectedPerson())) return
if (isDemoMode) {
// Démo: édition locale uniquement, jamais de persistance serveur.
editMode.saveDraft()
editMode.deactivate()
setContribSubmitError('')
setShowContribModal(false)
return
}
if (userRole.isAdmin) {
const token = auth.userAuth.token
const treeId = tree.treeContext.treeId
const draft = editMode.restoreDraft()
if (token && treeId) {
// Appliquer les modifications de personnes depuis le draft
const modifiedPersons = draft.modifiedPersons || {}
for (const [personId, rawChanges] of Object.entries(modifiedPersons)) {
const payload = {}
if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || ''
if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || ''
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive')) {
if (rawChanges.isAlive === true) payload.deathDate = null
payload.isDeceased = rawChanges.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null
if (Object.keys(payload).length > 0) {
try {
await updatePerson(treeId, personId, payload, token)
} catch (err) {
console.error('Failed to update person:', personId, err)
}
}
}
// Appliquer les annotations
const addedAnnIds = new Set(draft.addedAnnotations || [])
const modifiedAnnIds = new Set(draft.modifiedAnnotations || [])
const deletedAnnIds = draft.deletedAnnotations || []
const annData = draft.annotationsData || []
const batchPayload = {
create: annData
.filter((a) => addedAnnIds.has(a.id))
.map((a) => ({
type: a.type,
x: a.x,
y: a.y,
content: a.content,
style: a.style || {},
zIndex: a.zIndex || 0,
})),
update: annData
.filter((a) => modifiedAnnIds.has(a.id))
.map((a) => ({
id: a.id,
x: a.x,
y: a.y,
content: a.content,
style: a.style || {},
zIndex: a.zIndex || 0,
})),
delete: deletedAnnIds,
}
const hasAnnotationChanges = batchPayload.create.length > 0 || batchPayload.update.length > 0 || batchPayload.delete.length > 0
if (hasAnnotationChanges) {
try {
await batchAnnotations(treeId, batchPayload, token)
} catch (err) {
console.error('Failed to submit annotations:', err)
}
}
}
await tree.refreshCurrentTreeGraph({ relayout: false })
editMode.clearDraft()
editMode.deactivate()
} else {
// Contributeur → ouvrir modal session nommée
setContribSubmitError('')
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
setShowContribModal(true)
}
}, [userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
const handleContribSessionSubmit = useCallback(async ({ comment, name }) => {
setContribSubmitLoading(true)
setContribSubmitError('')
try {
const draft = editMode.restoreDraft()
const changes = buildContributionChangesFromDraft(draft)
if (changes.length === 0) {
setContribSubmitError(t('Aucune modification à soumettre.'))
return
}
const now = new Date()
const dateStr = now.toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: 'numeric' })
const contributorName = (name || auth.userAuth.user?.name || '').trim()
if (name) saveContributorName(name)
const title = (contributorName
? t('Contribution de {name} · {date}', { name: contributorName, date: dateStr })
: t('Contribution de la famille · {date}', { date: dateStr })).slice(0, 120)
const result = await contrib.handleSubmitContributionSession({
title,
comment: comment || null,
...(contributorName ? { submittedByLabel: contributorName } : {}),
changes,
})
if (!result?.ok) {
setContribSubmitError(result?.error || t("Erreur lors de l'envoi des contributions"))
return
}
editMode.clearDraft()
editMode.deactivate()
setShowContribModal(false)
setContribSubmitError('')
setContribSuccess(true)
setDraftNotice('')
setTimeout(() => setContribSuccess(false), 6000)
} catch (err) {
setContribSubmitError(err.message || t("Erreur lors de l'envoi des contributions"))
} finally {
setContribSubmitLoading(false)
}
}, [contrib, editMode, auth.userAuth.user]) // eslint-disable-line react-hooks/exhaustive-deps
const resolveContributionShareUrl = useCallback(async () => {
const slugFromPath = getSlugFromPathname()
if (slugFromPath) {
setContribInviteState((current) => ({
...current,
shareUrl: `${window.location.origin}/arbre/${encodeURIComponent(slugFromPath)}`,
}))
return
}
if (!tree.treeContext.treeId) {
setContribInviteState((current) => ({ ...current, shareUrl: '' }))
return
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken) {
setContribInviteState((current) => ({ ...current, shareUrl: '' }))
return
}
try {
const result = await getTree(tree.treeContext.treeId, accessToken)
const slug = result?.tree?.slug || ''
setContribInviteState((current) => ({
...current,
shareUrl: slug ? `${window.location.origin}/arbre/${encodeURIComponent(slug)}` : '',
}))
} catch {
setContribInviteState((current) => ({ ...current, shareUrl: '' }))
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handleOpenContributionPanel = useCallback(async () => {
const treeId = tree.treeContext.treeId
await resolveContributionShareUrl()
let share = ''
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (treeId && accessToken && tree.canEditCurrentTree) {
try {
share = (await fetchSharePassword(treeId, accessToken))?.share || ''
} catch {
share = ''
}
}
setContribInviteState((current) => ({
...current,
knownPasswords: { share },
error: '',
message: '',
}))
await contrib.handleOpenContributionPanel()
}, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps
const handleContributionPasswordRemember = useCallback(async ({ password }) => {
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (!tree.treeContext.treeId || !accessToken) {
return { ok: false, error: t('Session invalide.') }
}
setContribInviteState((current) => ({ ...current, loading: true, error: '', message: '' }))
try {
const result = await rememberSharePassword(tree.treeContext.treeId, String(password || '').trim(), accessToken)
setContribInviteState((current) => ({
...current,
knownPasswords: { share: result?.share || '' },
loading: false,
error: '',
message: t("C'est bien lui. Rien ne change pour la famille."),
}))
return { ok: true }
} catch (error) {
const message = error?.status === 422
? t("Ce n'est pas le mot de passe actuel de l'arbre.")
: getAccountErrorMessage(error)
setContribInviteState((current) => ({ ...current, loading: false, error: message, message: '' }))
return { ok: false, error: message }
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handleContributionPasswordRotate = useCallback(async ({ password }) => {
if (!tree.treeContext.treeId) {
return { ok: false, error: t('Arbre introuvable.') }
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken) {
return { ok: false, error: t('Session invalide.') }
}
const nextPassword = String(password || '').trim()
if (nextPassword.length < 8) {
return { ok: false, error: t('Minimum 8 caractères.') }
}
if (nextPassword === contribInviteState.knownPasswords.share) {
setContribInviteState((current) => ({
...current,
loading: false,
error: '',
message: t("C'est déjà le mot de passe actuel."),
}))
return { ok: true }
}
const payload = { password: nextPassword }
setContribInviteState((current) => ({
...current,
loading: true,
error: '',
message: '',
}))
try {
await rotateTreePasswords(tree.treeContext.treeId, payload, accessToken)
const storedPasswords = { share: nextPassword }
setContribInviteState((current) => ({
...current,
knownPasswords: storedPasswords,
loading: false,
error: '',
message: t('Mot de passe changé. Envoyez le nouveau à la famille.'),
}))
return { ok: true }
} catch (error) {
const message = getAccountErrorMessage(error)
setContribInviteState((current) => ({
...current,
loading: false,
error: message,
message: '',
}))
return { ok: false, error: message }
}
}, [auth.userAuth.token, contribInviteState.knownPasswords.share, tree.treeContext.accessToken, tree.treeContext.treeId])
// Sauvegarde modification personne (mode édition)
// Admin → appel API direct et immédiat.
// Contributeur/membre → mise dans le draft local, soumis ensuite pour approbation.
const handlePersonSave = useCallback(async (personId, changes) => {
if (!tree.canEditCurrentTree) {
// Mode contribution : accumuler dans le draft local
const draft = editMode.restoreDraft()
const modifiedPersons = draft.modifiedPersons || {}
modifiedPersons[personId] = { ...modifiedPersons[personId], ...changes }
editMode.updateDraft({ modifiedPersons })
const localPerson = persons.find((candidate) => String(candidate.id) === String(personId))
if (localPerson) {
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) {
const iso = yearToIsoDate(changes.birthYear)
localPerson.birthDate = iso
localPerson.birthYear = iso ? Number(iso.slice(0, 4)) : null
}
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) {
const iso = yearToIsoDate(changes.deathYear)
localPerson.deathDate = iso
localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null
}
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null
if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null
if (Object.prototype.hasOwnProperty.call(changes, 'note')) localPerson.note = changes.note || null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) {
if (changes.isAlive === true) {
localPerson.deathYear = null
localPerson.deathDate = null
}
localPerson.isAlive = changes.isAlive === true
localPerson.isDeceased = changes.isAlive === false && !localPerson.deathYear
}
tree.setSelectedPerson({ ...localPerson })
}
return
}
// Admin : appliquer directement via l'API
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
const payload = {}
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear)
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear)
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) {
if (changes.isAlive === true) payload.deathDate = null
payload.isDeceased = changes.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null
if (Object.keys(payload).length > 0) {
await updatePerson(tree.treeContext.treeId, personId, payload, accessToken)
}
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) // eslint-disable-line react-hooks/exhaustive-deps
const handlePersonAvatarUpload = useCallback(async (personId, file) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setAvatarActionState({ loading: true, error: '' })
const prepared = await prepareImageForUpload(file)
const validationError = validateMediaFile(prepared, 'photo')
if (validationError) {
setAvatarActionState({ loading: false, error: validationError })
return
}
try {
const dataBase64 = await fileToBase64(prepared)
await uploadPersonAvatar(
tree.treeContext.treeId,
personId,
{
fileName: prepared.name || 'avatar.jpg',
mimeType: getUploadMimeType(prepared, 'photo'),
sizeBytes: prepared.size,
dataBase64,
},
accessToken,
)
trackAppEvent('portrait_added', { via: 'card' })
await refreshTreeAndKeepSelection(personId, { relayout: true })
setAvatarActionState({ loading: false, error: '' })
// Aperçu de la photo envoyée, pour la fenêtre de recadrage qui s'ouvre tout de suite
return { previewUrl: URL.createObjectURL(prepared) }
} catch (error) {
setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) })
return null
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Recadrage du portrait enregistré : true si c'est fait, la fenêtre se ferme alors
const handlePersonAvatarCrop = useCallback(async (personId, crop) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return false
setAvatarActionState({ loading: true, error: '' })
try {
await saveAvatarCrop(tree.treeContext.treeId, personId, crop, accessToken)
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
setAvatarActionState({ loading: false, error: '' })
return true
} catch (error) {
setAvatarActionState({
loading: false,
error: error?.payload?.error === 'avatar_from_immich'
? t('Les photos venues d’Immich ne se recadrent pas pour l’instant.')
: getAccountErrorMessage(error),
})
return false
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant)
const handleImmichChange = useCallback(async (personId) => {
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
}, [refreshTreeAndKeepSelection])
const handlePersonAvatarDelete = useCallback(async (personId, avatarFromImmich = false) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setAvatarActionState({ loading: true, error: '' })
try {
// Portrait venu d'Immich : le désassignement retire le portrait et l'association d'un
// coup ; la suppression seule laisserait une association pendante en base.
if (avatarFromImmich) {
await unassignImmichPerson(tree.treeContext.treeId, personId, accessToken)
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
} else {
await deletePersonAvatar(tree.treeContext.treeId, personId, accessToken)
await refreshTreeAndKeepSelection(personId, { relayout: true })
}
setAvatarActionState({ loading: false, error: '' })
} catch (error) {
setAvatarActionState({
loading: false,
error: avatarFromImmich ? getImmichErrorMessage(error, t) : getAccountErrorMessage(error),
})
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Suppression personne (mode édition, admin)
const handlePersonDelete = useCallback(async (personId) => {
if (!tree.treeContext.treeId && !isDemoMode) return
if (isDemoMode) {
for (let idx = persons.length - 1; idx >= 0; idx -= 1) {
if (String(persons[idx].id) === String(personId)) {
persons.splice(idx, 1)
}
}
const removedUnionIds = new Set()
for (let idx = unions.length - 1; idx >= 0; idx -= 1) {
const union = unions[idx]
if (
String(union.partner1Id) === String(personId)
|| String(union.partner2Id) === String(personId)
) {
removedUnionIds.add(union.id)
unions.splice(idx, 1)
}
}
for (let idx = filiations.length - 1; idx >= 0; idx -= 1) {
const filiation = filiations[idx]
const touchesPerson = (
String(filiation.childId) === String(personId)
|| String(filiation.parentId) === String(personId)
)
const touchesRemovedUnion = filiation.unionId && removedUnionIds.has(filiation.unionId)
if (touchesPerson || touchesRemovedUnion) {
filiations.splice(idx, 1)
}
}
for (let idx = medias.length - 1; idx >= 0; idx -= 1) {
if (String(medias[idx].personId) === String(personId)) {
medias.splice(idx, 1)
}
}
tree.setSelectedPerson(null)
tree.bumpGraphRevision()
return
}
if (tree.canEditCurrentTree) {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken) return
await deletePerson(tree.treeContext.treeId, personId, accessToken)
await refreshTreeAndKeepSelection(null, { relayout: true })
tree.setSelectedPerson(null)
return
}
// Mettre a jour le draft
const draft = editMode.restoreDraft()
const deletedPersons = [...(draft.deletedPersons || []), personId]
editMode.updateDraft({ deletedPersons })
tree.setSelectedPerson(null)
}, [auth.userAuth.token, refreshTreeAndKeepSelection, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
// Ajout d'une relation (mode édition)
const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
if (isDemoMode) {
let relationAdded = false
if (relationType === 'spouse') {
const existsUnion = unions.some((candidate) => (
(String(candidate.partner1Id) === String(sourcePersonId) && String(candidate.partner2Id) === String(targetPersonId))
|| (String(candidate.partner1Id) === String(targetPersonId) && String(candidate.partner2Id) === String(sourcePersonId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: sourcePersonId,
partner2Id: targetPersonId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
})
relationAdded = true
}
} else {
const childId = relationType === 'parent' ? sourcePersonId : targetPersonId
const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId
const existsLink = filiations.some((candidate) => (
String(candidate.childId) === String(childId)
&& String(candidate.parentId) === String(parentId)
))
if (!existsLink) {
filiations.push({
id: makeLocalId('local-filiation'),
childId,
parentId,
unionId: null,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
relationAdded = true
}
}
if (!relationAdded) {
setRelationFeedback({ loading: false, success: '', error: t('Lien déjà présent') })
return
}
const draft = editMode.restoreDraft()
const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ addedRelations })
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien familial ajouté (démo locale)'), error: '' })
return
}
if (!tree.canEditCurrentTree) {
const draft = editMode.restoreDraft()
const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ addedRelations })
setRelationFeedback({ loading: false, success: t('Lien ajouté à la contribution'), error: '' })
return
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setRelationFeedback({ loading: true, success: '', error: '' })
try {
if (relationType === 'parent') {
// sourcePersonId est l'enfant, targetPersonId est le parent
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: targetPersonId, childPersonId: sourcePersonId, parentageType: 'biologique', displayOrder: 1 },
accessToken
)
} else if (relationType === 'child') {
// sourcePersonId est le parent, targetPersonId est l'enfant
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: sourcePersonId, childPersonId: targetPersonId, parentageType: 'biologique', displayOrder: 1 },
accessToken
)
} else if (relationType === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: sourcePersonId, partner2PersonId: targetPersonId, unionType: 'mariage', displayOrder: 1 },
accessToken
)
}
// Mettre à jour le draft
const draft = editMode.restoreDraft()
const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ addedRelations })
await refreshTreeAndKeepSelection(sourcePersonId, { relayout: true })
setRelationFeedback({ loading: false, success: t('Lien familial ajouté'), error: '' })
} catch (err) {
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
console.error('Failed to add relation:', err)
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
// Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi
const [pendingParentage, setPendingParentage] = useState({})
useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps
// Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne
const handleSetParentage = useCallback(async (childPersonId, parentageType) => {
const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId))
if (childLinks.length === 0) return
if (isDemoMode) {
for (const link of childLinks) link.parentageType = parentageType
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
return
}
if (!tree.canEditCurrentTree) {
const draft = editMode.restoreDraft()
const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType }
editMode.updateDraft({ parentageChanges })
setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType }))
setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' })
return
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setRelationFeedback({ loading: true, success: '', error: '' })
try {
for (const link of childLinks) {
if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue
await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken)
}
await refreshTreeAndKeepSelection(childPersonId, { relayout: true })
setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' })
} catch (err) {
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
console.error('Failed to update parentage:', err)
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
// Suppression d'une relation (mode édition)
const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
if (isDemoMode) {
let relationRemoved = false
if (relationType === 'spouse') {
for (let idx = unions.length - 1; idx >= 0; idx -= 1) {
const union = unions[idx]
if (
(String(union.partner1Id) === String(sourcePersonId) && String(union.partner2Id) === String(targetPersonId))
|| (String(union.partner1Id) === String(targetPersonId) && String(union.partner2Id) === String(sourcePersonId))
) {
const removedUnionId = union.id
unions.splice(idx, 1)
for (let fIdx = filiations.length - 1; fIdx >= 0; fIdx -= 1) {
if (filiations[fIdx].unionId && String(filiations[fIdx].unionId) === String(removedUnionId)) {
filiations.splice(fIdx, 1)
}
}
relationRemoved = true
break
}
}
} else {
const childId = relationType === 'parent' ? sourcePersonId : targetPersonId
const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId
for (let idx = filiations.length - 1; idx >= 0; idx -= 1) {
const filiation = filiations[idx]
if (
String(filiation.childId) === String(childId)
&& String(filiation.parentId) === String(parentId)
) {
filiations.splice(idx, 1)
relationRemoved = true
}
}
}
if (!relationRemoved) {
setRelationFeedback({ loading: false, success: '', error: t('Relation introuvable pour suppression') })
return
}
const draft = editMode.restoreDraft()
const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ removedRelations })
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien familial supprimé (démo locale)'), error: '' })
return
}
if (!tree.canEditCurrentTree) {
const draft = editMode.restoreDraft()
const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ removedRelations })
setRelationFeedback({ loading: false, success: t('Suppression de lien ajoutée à la contribution'), error: '' })
return
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setRelationFeedback({ loading: true, success: '', error: '' })
try {
let relationRemoved = false
if (relationType === 'spouse') {
const union = unions.find((candidate) => (
(String(candidate.partner1Id) === String(sourcePersonId) && String(candidate.partner2Id) === String(targetPersonId)) ||
(String(candidate.partner1Id) === String(targetPersonId) && String(candidate.partner2Id) === String(sourcePersonId))
))
if (union) {
await deleteUnion(tree.treeContext.treeId, union.id, accessToken)
relationRemoved = true
}
} else {
const childId = relationType === 'parent' ? sourcePersonId : targetPersonId
const parentId = relationType === 'parent' ? targetPersonId : sourcePersonId
let link = filiations.find((candidate) => (
String(candidate.childId) === String(childId) &&
String(candidate.parentId) === String(parentId)
))
if (!link) {
const relatedUnionIds = new Set(
unions
.filter((candidate) => (
String(candidate.partner1Id) === String(parentId) ||
String(candidate.partner2Id) === String(parentId)
))
.map((candidate) => candidate.id),
)
link = filiations.find((candidate) => (
String(candidate.childId) === String(childId) &&
candidate.unionId &&
relatedUnionIds.has(candidate.unionId)
))
}
if (link) {
await deleteParentChildLink(tree.treeContext.treeId, link.id, accessToken)
relationRemoved = true
}
}
if (!relationRemoved) {
setRelationFeedback({ loading: false, success: '', error: t('Relation introuvable pour suppression') })
return
}
const draft = editMode.restoreDraft()
const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ removedRelations })
await refreshTreeAndKeepSelection(sourcePersonId, { relayout: true })
setRelationFeedback({ loading: false, success: t('Lien familial supprimé'), error: '' })
} catch (err) {
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
console.error('Failed to remove relation:', err)
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
// Raccourcis clavier : Ctrl+E (toggle), Ctrl+S (save), ESC (quit)
useEffect(() => {
function handleKeyDown(e) {
// Ignorer si focus dans un input/textarea
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
handleEditToggle()
} else if (e.ctrlKey && e.key === 's') {
if (editMode.isActive) {
e.preventDefault()
editMode.saveDraft()
}
} else if (e.key === 'Escape') {
if (annot.selectedAnnotationId) {
annot.selectAnnotation(null)
} else if (editMode.isActive) {
handleEditToggle()
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (editMode.isActive && annot.selectedAnnotationId) {
e.preventDefault()
annot.deleteSelectedAnnotation()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}) // re-bind chaque render pour capter isActive/hasDraft à jour
useEffect(() => {
if (!tree.canEditCurrentTree) {
setAdminPanelVisible(false)
}
if (!canEditInCurrentMode) {
editMode.deactivate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree.canEditCurrentTree, canEditInCurrentMode])
const openAccountTree = useCallback(async (entry, token, options = {}) => {
const accessMode = entry?.accessMode || 'member'
const role = entry?.role || (accessMode === 'share' ? 'contributor' : 'member')
await tree.openTreeGraph(entry.id, token, {
treeName: entry?.name || '',
treeDescription: entry?.description || '',
treeOwnerName: entry?.ownerName || '',
treeOwnerEmail: entry?.ownerEmail || '',
role,
accessMode,
promptWizard: Boolean(options.promptWizard),
})
auth.persistLastOpenedTree({
treeId: entry.id,
accessMode,
})
}, [auth.persistLastOpenedTree, tree.openTreeGraph])
// Bootstrap
useEffect(() => {
let active = true
const bootstrap = async () => {
const query = new URLSearchParams(window.location.search)
// Explicit demo modes (landing iframe / forced demo)
if (query.has('embed') || query.has('demo')) {
tree.handleUseDemo()
return
}
const slug = getSlugFromPathname()
let resolvedTreeId = TREE_ID_FROM_ENV
let resolvedTreeName = ''
let resolvedTreeDescription = ''
const resolvedTreeOwnerName = ''
const resolvedTreeOwnerEmail = ''
// Root app:
// - not connected => accueil à deux choix (variante B, 18/10)
// - connected => continue bootstrap to open the user's tree
if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) {
const userToken = auth.restoreUserToken()
if (!userToken) {
tree.setBootState('welcome')
return
}
}
setTreeNotFound(false)
if (slug) {
try {
const treeInfo = await resolveTreeBySlug(slug)
resolvedTreeId = treeInfo.id
resolvedTreeName = treeInfo.name || ''
resolvedTreeDescription = treeInfo.description || ''
} catch (error) {
if (!active) return
tree.setBootState('need-access')
tree.setTreeContext(buildEmptyTreeContext())
const notFound = error?.status === 404 || error?.payload?.error === 'tree_not_found'
setTreeNotFound(notFound)
tree.setGateError(notFound ? t("Cet arbre n'existe pas.") : t('Impossible de résoudre ce lien de partage.'))
// Lien mort, mais la session du compte reste valable : « Créer mon arbre » ou « Mon compte »
// doivent ouvrir le compte connecté, pas un écran de connexion vide
const storedUserToken = auth.restoreUserToken()
if (storedUserToken) {
try {
await auth.loadAccountTrees(storedUserToken)
if (!active) return
auth.setUserAuth({ token: storedUserToken, user: auth.restoreUserProfile?.() || null })
} catch {
auth.clearStoredUserToken()
}
}
return
}
}
const userToken = auth.restoreUserToken()
if (userToken) {
try {
const { trees, lastOpenedTree } = await auth.loadAccountTrees(userToken)
if (!active) return
auth.setUserAuth({
token: userToken,
user: auth.restoreUserProfile?.() || null,
})
if (resolvedTreeId) {
const resolvedTree = trees.find((entry) => String(entry.id) === String(resolvedTreeId))
if (resolvedTree) {
try {
await openAccountTree(resolvedTree, userToken, { promptWizard: true })
return
} catch {
// Fallback vers ecran partage
}
}
tree.setTreeContext(buildEmptyTreeContext({
treeId: resolvedTreeId,
treeName: resolvedTreeName,
treeDescription: resolvedTreeDescription,
treeOwnerName: resolvedTreeOwnerName,
treeOwnerEmail: resolvedTreeOwnerEmail,
}))
tree.setGateError('')
tree.setBootState('need-access')
return
}
const preferredTree = lastOpenedTree
? trees.find((entry) =>
String(entry.id) === String(lastOpenedTree.treeId)
&& (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode))
: null
if (preferredTree) {
try {
await openAccountTree(preferredTree, userToken, { promptWizard: true })
return
} catch {
// Fallback sur arbre suivant
}
}
if (trees.length > 0) {
try {
await openAccountTree(trees[0], userToken, { promptWizard: true })
return
} catch {
auth.setAccountError(t("Aucun arbre accessible n'a pu être chargé automatiquement."))
}
tree.setBootState('account')
return
}
// Premier utilisateur : aucun arbre → galaxie démo + bouton onboarding
tree.handleUseDemo()
setAccountEntryMode('register')
setTreeWizardVisible(true)
return
} catch {
auth.clearStoredUserToken()
}
}
if (resolvedTreeId) {
const shareToken = localStorage.getItem(getTreeTokenStorageKey(resolvedTreeId))
if (shareToken) {
try {
await tree.openTreeGraph(resolvedTreeId, shareToken, {
treeName: resolvedTreeName,
treeDescription: resolvedTreeDescription,
treeOwnerName: resolvedTreeOwnerName,
treeOwnerEmail: resolvedTreeOwnerEmail,
// Rôle lu dans le jeton : un contributeur qui recharge la page reste contributeur
role: readTreeAccessRole(shareToken),
accessMode: 'share',
promptWizard: false,
})
return
} catch {
localStorage.removeItem(getTreeTokenStorageKey(resolvedTreeId))
}
}
}
if (!active) return
if (resolvedTreeId) {
tree.setTreeContext(buildEmptyTreeContext({
treeId: resolvedTreeId,
treeName: resolvedTreeName,
treeDescription: resolvedTreeDescription,
treeOwnerName: resolvedTreeOwnerName,
treeOwnerEmail: resolvedTreeOwnerEmail,
}))
tree.setGateError('')
tree.setBootState('need-access')
return
}
// ?account=register (CTA de la landing) : écran compte directement en création
if (query.get('account') === 'register') {
setAccountEntryMode('register')
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() })
}
tree.setBootState('account')
}
bootstrap()
return () => {
active = false
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleUseDemo = () => {
setTreeNotFound(false)
tree.handleUseDemo()
auth.setAccountError('')
setAccountPanelVisible(false)
setTreeWelcomeVisible(false)
setTreeWizardVisible(false)
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
}
// Fallback auto: si l'utilisateur est connecté mais n'a aucun arbre,
// on bascule automatiquement sur la démo (utile en production quand la création d'account laisse l'UI vide).
// Sauf s'il a ouvert lui-même « Mon compte » : l'écran « Vos arbres » sait afficher un compte vide, et c'est
// là que se trouvent la déconnexion et « Supprimer mon compte » (sans ça, introuvables sans arbre).
useEffect(() => {
if (
tree.bootState === 'account' &&
auth.authenticated &&
!auth.accountLoading &&
auth.accountTrees.length === 0 &&
!accountFromTree &&
!pendingSharedTreeRef.current // retour sur un arbre partagé en cours : pas d'assistant "votre arbre"
) {
handleUseDemo()
setAccountEntryMode('register')
setTreeWizardVisible(true)
}
}, [tree.bootState, auth.authenticated, auth.accountLoading, auth.accountTrees.length, accountFromTree]) // eslint-disable-line react-hooks/exhaustive-deps
const handleUseAccount = (mode = 'login') => {
const ctx = tree.treeContext
pendingSharedTreeRef.current = ctx.treeId && ctx.accessMode !== 'demo' && ctx.accessMode !== 'member'
? {
treeId: ctx.treeId,
treeName: ctx.treeName,
treeDescription: ctx.treeDescription,
treeOwnerName: ctx.treeOwnerName,
treeOwnerEmail: ctx.treeOwnerEmail,
}
: null
setPendingSharedTreeName(pendingSharedTreeRef.current?.treeName || '')
setTreeNotFound(false)
tree.setGateError('')
auth.setAccountError('')
setAccountPanelVisible(false)
setAccountEntryMode(mode === 'register' ? 'register' : 'login')
trackAppEvent('account_screen_viewed', { mode: mode === 'register' ? 'register' : 'login', from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
setAccountFromTree(tree.bootState === 'ready')
tree.setBootState('account')
}
const handleBackToTree = () => {
auth.setAccountError('')
setAccountFromTree(false)
tree.setBootState('ready')
}
const handleBackToAccess = () => {
setAccountFromTree(false)
setTreeNotFound(false)
auth.setAccountError('')
setAccountPanelVisible(false)
setAccountEntryMode('login')
tree.setGateError('')
tree.setWizardVisibility(false)
setTreeWizardVisible(false)
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
tree.setBootState('need-access')
}
// Après connexion ou création de compte depuis un arbre partagé : rattacher l'accès au compte
// (jeton d'arbre déjà en poche) et rouvrir l'arbre ; sans jeton, retour à la grille d'accès de
// cet arbre, où le mot de passe fera le rattachement. Renvoie true si le retour a été géré.
const resumePendingSharedTree = async (token) => {
const pending = pendingSharedTreeRef.current
if (!pending?.treeId) {
return false
}
// Le ref reste posé jusqu'à la fin : il bloque l'assistant "votre arbre" pendant le rattachement
try {
const shareToken = localStorage.getItem(getTreeTokenStorageKey(pending.treeId))
if (shareToken) {
try {
await linkTreeAccess(pending.treeId, shareToken, token)
const { trees } = await auth.loadAccountTrees(token)
const entry = trees.find((candidate) => String(candidate.id) === String(pending.treeId))
if (entry) {
await openAccountTree(entry, token, { promptWizard: false })
return true
}
} catch {
// Jeton expiré ou refusé : on repasse par le mot de passe
}
}
tree.setTreeContext(buildEmptyTreeContext(pending))
tree.setGateError('')
tree.setBootState('need-access')
return true
} finally {
pendingSharedTreeRef.current = null
setPendingSharedTreeName('')
}
}
const handleLogin = async (email, password) => {
const success = await auth.handleLogin(email, password)
if (!success) {
return
}
const token = auth.restoreUserToken()
if (!token) {
tree.setBootState('account')
return
}
if (await resumePendingSharedTree(token)) {
return
}
const { trees, lastOpenedTree } = await auth.loadAccountTrees(token)
const preferredTree = lastOpenedTree
? trees.find((entry) =>
String(entry.id) === String(lastOpenedTree.treeId)
&& (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode))
: null
const target = preferredTree || trees[0] || null
if (!target) {
handleUseDemo()
setAccountEntryMode('register')
setTreeWizardVisible(true)
return
}
try {
await openAccountTree(target, token, { promptWizard: true })
} catch {
tree.setBootState('account')
}
}
const handleRegister = async (firstName, email, password) => {
const success = await auth.handleRegister(firstName, email, password)
if (!success) {
return
}
trackAppEvent('account_registered', { from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), source: getSource() })
const token = auth.restoreUserToken()
if (token && await resumePendingSharedTree(token)) {
return
}
handleUseDemo()
setAccountEntryMode('register')
setTreeWizardVisible(true)
}
const handleOpenTreeFromAccount = async (treeId) => {
if (!auth.userAuth.token) {
return
}
auth.setAccountLoading(true)
auth.setAccountError('')
try {
const treeInfo = auth.accountTrees.find((candidate) => String(candidate.id) === String(treeId))
if (!treeInfo) {
return
}
await openAccountTree(treeInfo, auth.userAuth.token, { promptWizard: true })
} catch (error) {
auth.setAccountError(getAccountErrorMessage(error))
} finally {
auth.setAccountLoading(false)
}
}
const handleOpenTreeWizard = () => {
trackDemoInteraction('create_tree')
auth.setAccountError('')
setTreeWelcomeVisible(false)
setTreeWizardVisible(true)
}
const handleCreateTreeFromWizard = async (payload) => {
const token = auth.userAuth.token
if (!token) {
return t('Session invalide ou expirée. Reconnectez-vous.')
}
setTreeWizardLoading(true)
auth.setAccountLoading(true)
auth.setAccountError('')
try {
const normalizedSlug = normalizeSlug(payload.treeName)
const safeSlug = normalizedSlug.length >= 3
? normalizedSlug
: `famille-${Date.now().toString(36)}`
// Un seul mot de passe de partage, gardé chiffré par le serveur pour que le propriétaire le voie
const sharePassword = generateSecurePassword()
const createdTree = await createTree(
{
name: payload.treeName,
slug: safeSlug,
description: null,
visitorPassword: sharePassword,
contributorPassword: sharePassword,
},
token,
)
trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) })
// Arbre déjà tenu dans un logiciel : on importe son fichier au lieu de commencer par « vous »,
// sinon la personne saisie ici existerait deux fois après l'import
if (payload.gedcomFile) {
let imported = null
let importError = ''
let bundledFiles = []
try {
// Un zip apporte le .ged et ses photos : le .ged s'importe, les photos attendent l'étape des visages
let bundle
try {
bundle = await readGedcomBundle(payload.gedcomFile)
} catch (error) {
throw new Error(error?.code === 'no_gedcom' ? error.message : zipErrorMessage(error), { cause: error })
}
bundledFiles = bundle.bundledFiles
const content = (await readGedcomFile(bundle.gedcomFile)).text
imported = content.trim()
? await importGedcom(createdTree.id, content, token)
: null
if (!imported) importError = t('Le fichier GEDCOM est vide.')
} catch (error) {
importError = (error?.cause && error.message) || getAccountErrorMessage(error) || t("Impossible d'importer ce fichier GEDCOM.")
}
await auth.loadAccountTrees(token)
await tree.openTreeGraph(createdTree.id, token, {
treeName: createdTree.name || payload.treeName,
treeDescription: createdTree.description || '',
role: 'owner',
accessMode: 'member',
rootPersonId: null,
promptWizard: false,
})
await auth.persistLastOpenedTree({ treeId: createdTree.id, accessMode: 'member' })
setTreeWizardVisible(false)
if (imported) {
const refs = Array.isArray(imported.photoRefs) ? imported.photoRefs : []
const mediaRefs = Array.isArray(imported.mediaRefs) ? imported.mediaRefs : []
savePhotoRefs(createdTree.id, refs)
saveMediaRefs(createdTree.id, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(imported.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
via: 'wizard',
})
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs, mediaRefs, bundledFiles })
} else {
// L'arbre existe déjà : on l'ouvre vide et on dit où réessayer, plutôt que d'en créer un second
setTreeImportError(importError)
}
return null
}
const createdSelf = await createPerson(
createdTree.id,
{
firstName: payload.selfFirstName,
lastName: payload.selfLastName,
birthDate: yearToIsoDate(payload.selfBirthYear),
},
token,
)
if (createdSelf?.id) {
await updateTree(
createdTree.id,
{ rootPersonId: createdSelf.id },
token,
)
if (payload.photoFile) {
const validationError = validateMediaFile(payload.photoFile, 'photo')
if (!validationError) {
try {
const dataBase64 = await fileToBase64(payload.photoFile)
await uploadPersonAvatar(
createdTree.id,
createdSelf.id,
{
fileName: payload.photoFile.name || 'avatar.jpg',
mimeType: getUploadMimeType(payload.photoFile, 'photo'),
sizeBytes: payload.photoFile.size,
dataBase64,
},
token,
)
} catch {
// La photo est optionnelle: ignorer l'echec sans bloquer la creation.
}
}
}
}
await auth.loadAccountTrees(token)
await tree.openTreeGraph(createdTree.id, token, {
treeName: createdTree.name || payload.treeName,
treeDescription: createdTree.description || '',
role: 'owner',
accessMode: 'member',
rootPersonId: createdSelf?.id || null,
promptWizard: false,
})
await auth.persistLastOpenedTree({
treeId: createdTree.id,
accessMode: 'member',
})
setTreeWizardVisible(false)
setTreeWelcomeVisible(true)
return null
} catch (error) {
const message = getAccountErrorMessage(error)
auth.setAccountError(message)
return message
} finally {
auth.setAccountLoading(false)
setTreeWizardLoading(false)
}
}
const handleLogout = async () => {
setTreeNotFound(false)
await auth.handleLogout()
resetSignedOutState()
}
// Accueil « J'ai reçu un lien de famille » : la porte demande le code de l'arbre et son mot de passe
const handleFamilyLinkChoice = () => {
tree.setTreeContext(buildEmptyTreeContext())
tree.setGateError('')
tree.setBootState('need-access')
}
// « Ce n'est pas mon compte » : déconnexion, puis la porte du même arbre
const handleNotMyAccount = async () => {
setTreeNotFound(false)
await auth.handleLogout()
tree.setGateError('')
tree.setBootState('need-access')
}
// Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée.
const handleDeleteAccount = async (password) => {
const errorMessage = await auth.handleDeleteAccount(password)
if (errorMessage) {
return errorMessage
}
setTreeNotFound(false)
resetSignedOutState()
return null
}
function resetSignedOutState() {
setAccountFromTree(false)
tree.setWizardState({ visible: false, loading: false, error: '' })
setAccountPanelVisible(false)
setTreeWizardVisible(false)
setTreeWelcomeVisible(false)
setAccountEntryMode('login')
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
tree.setBootState('account')
}
const handleGateUnlock = async (treeId, password) => {
const access = await tree.handleUnlock(treeId, password, {
userToken: auth.userAuth.token || '',
treeName: tree.treeContext.treeName,
treeDescription: tree.treeContext.treeDescription,
treeOwnerName: tree.treeContext.treeOwnerName,
treeOwnerEmail: tree.treeContext.treeOwnerEmail,
})
if (!access) {
return
}
setTreeNotFound(false)
if (auth.userAuth.token) {
await auth.loadAccountTrees(auth.userAuth.token)
auth.persistLastOpenedTree({
treeId,
accessMode: 'share',
})
}
}
const handleTreeDeleted = async (deletedTreeId) => {
if (!auth.userAuth.token) {
handleUseAccount('login')
return
}
try {
const { trees, lastOpenedTree } = await auth.loadAccountTrees(auth.userAuth.token)
const preferredTree = lastOpenedTree
? trees.find((entry) =>
String(entry.id) === String(lastOpenedTree.treeId)
&& (!lastOpenedTree.accessMode || entry.accessMode === lastOpenedTree.accessMode))
: null
const nextTree = trees.find((entry) => String(entry.id) !== String(deletedTreeId)) || preferredTree || trees[0] || null
if (nextTree) {
await openAccountTree(nextTree, auth.userAuth.token, { promptWizard: true })
return
}
handleUseDemo()
setAccountEntryMode('register')
setTreeWizardVisible(true)
} catch {
tree.setBootState('account')
}
}
const handleGedcomImported = useCallback(async (result, treeIdOverride = null, bundledFiles = []) => {
await refreshTreeAndKeepSelection(null, { relayout: true })
const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : []
const mediaRefs = Array.isArray(result?.mediaRefs) ? result.mediaRefs : []
savePhotoRefs(treeIdOverride || tree.treeContext.treeId, refs)
saveMediaRefs(treeIdOverride || tree.treeContext.treeId, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(result?.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
})
setAdminPanelVisible(false)
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs, mediaRefs, bundledFiles })
}, [refreshTreeAndKeepSelection, openPhotoStart, tree.treeContext.treeId])
// Un portrait envoyé sans recharger l'arbre (rechargé une fois, à la fermeture de la fenêtre).
// Renvoie un message d'erreur, ou '' si c'est fait.
const sendPortrait = useCallback(async (personId, file) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return t('Session expirée, reconnectez-vous.')
// Photo d'un zip : décompressée seulement maintenant
const prepared = await prepareImageForUpload(await materializeFile(file))
const validationError = validateMediaFile(prepared, 'photo')
if (validationError) return validationError
const payload = {
fileName: prepared.name || 'avatar.jpg',
mimeType: getUploadMimeType(prepared, 'photo'),
sizeBytes: prepared.size,
dataBase64: await fileToBase64(prepared),
}
// Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus
for (let attempt = 0; ; attempt += 1) {
try {
await uploadPersonAvatar(tree.treeContext.treeId, personId, payload, accessToken)
photoStartDirtyRef.current = true
return ''
} catch (error) {
if (error?.status === 429 && attempt < 2) {
await new Promise((resolve) => window.setTimeout(resolve, 15000))
continue
}
return getAccountErrorMessage(error)
}
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handlePhotoStartUploadOne = useCallback(async (personId, file) => {
const error = await sendPortrait(personId, file)
if (!error) trackAppEvent('portrait_added', { via: 'first_faces' })
return error
}, [sendPortrait])
const handlePhotoStartBulk = useCallback(async (matches, onProgress) => {
const placedIds = []
for (let i = 0; i < matches.length; i += 1) {
const error = await sendPortrait(matches[i].personId, matches[i].file)
if (!error) placedIds.push(matches[i].personId)
onProgress?.(i + 1)
}
trackAppEvent('portraits_bulk_placed', { placed: sizeBucket(placedIds.length), failed: sizeBucket(matches.length - placedIds.length) })
return { placedIds }
}, [sendPortrait])
// Les autres fichiers retrouvés dans le dossier : des souvenirs, avec le titre et la source du logiciel
const handlePhotoStartMedia = useCallback(async (matches, onProgress) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
const added = []
for (let i = 0; i < matches.length; i += 1) {
const { personId, caption, source } = matches[i]
try {
if (!accessToken || !tree.treeContext.treeId) break
const file = await materializeFile(matches[i].file)
const prepared = await prepareImageForUpload(file)
const type = inferMediaTypeFromMimeType(prepared.type)
if (!validateMediaFile(prepared, type)) {
const payload = {
type,
fileName: prepared.name || file.name,
mimeType: getUploadMimeType(prepared, type),
sizeBytes: prepared.size,
dataBase64: await fileToBase64(prepared),
caption: caption ? String(caption).slice(0, 280) : null,
source: source ? String(source).slice(0, 500) : null,
}
// Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus
for (let attempt = 0; ; attempt += 1) {
try {
await uploadPersonMedia(tree.treeContext.treeId, personId, payload, accessToken)
added.push(matches[i])
photoStartDirtyRef.current = true
break
} catch (error) {
if (error?.status === 429 && attempt < 2) {
await new Promise((resolve) => window.setTimeout(resolve, 15000))
continue
}
break
}
}
}
} catch {
// Fichier illisible : compté comme non envoyé
}
onProgress?.(i + 1)
}
trackAppEvent('memories_bulk_added', { added: sizeBucket(added.length), failed: sizeBucket(matches.length - added.length) })
return { added }
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handlePhotoStartClose = useCallback(async () => {
setPhotoStart((current) => ({ ...current, visible: false }))
if (photoStartDirtyRef.current) {
photoStartDirtyRef.current = false
await refreshTreeAndKeepSelection(null, { relayout: true, avatarCacheBust: Date.now() })
}
}, [refreshTreeAndKeepSelection])
const handleSelectSelfPerson = (personId) => {
const treeId = tree.treeContext.treeId
if (!treeId || !accountEmail) {
return
}
const nextValue = personId ? String(personId) : ''
setLinkedSelfPersonId(nextValue)
try {
const storageKey = getAccountSelfPersonStorageKey(accountEmail, treeId)
if (nextValue) {
localStorage.setItem(storageKey, nextValue)
} else {
localStorage.removeItem(storageKey)
}
} catch {
// Ignore storage failures
}
}
if (tree.bootState === 'loading') {
return (
<div className="app app-loading" />
)
}
if (tree.bootState === 'welcome') {
return (
<div className="app">
<EntryChoice
onFamilyLink={handleFamilyLinkChoice}
onLogin={() => handleUseAccount('login')}
onRegister={() => handleUseAccount('register')}
/>
</div>
)
}
if (tree.bootState === 'need-access') {
return (
<div className="app">
<AccessGate
initialTreeId={tree.treeContext.treeId}
treeName={tree.treeContext.treeName}
treeDescription={tree.treeContext.treeDescription}
treeOwnerName={tree.treeContext.treeOwnerName}
treeOwnerEmail={tree.treeContext.treeOwnerEmail}
treeNotFound={treeNotFound}
loading={tree.gateLoading}
errorMessage={tree.gateError}
connected={Boolean(auth.userAuth.token)}
connectedName={accountUser?.firstName || ''}
onNotMyAccount={handleNotMyAccount}
onSubmit={handleGateUnlock}
onUseDemo={handleUseDemo}
onUseAccount={handleUseAccount}
/>
</div>
)
}
if (tree.bootState === 'account') {
return (
<div className="app">
<AccountDashboard
authenticated={auth.authenticated}
loading={auth.accountLoading}
errorMessage={auth.accountError}
trees={auth.accountTrees}
activeTreeId={tree.treeContext.treeId}
defaultMode={accountEntryMode}
pendingTreeName={pendingSharedTreeName}
onLogin={handleLogin}
onRegister={handleRegister}
onOpenTree={handleOpenTreeFromAccount}
onStartTreeWizard={handleOpenTreeWizard}
onLogout={handleLogout}
onDeleteAccount={handleDeleteAccount}
onBackToAccess={handleBackToAccess}
onBackToTree={accountFromTree ? handleBackToTree : undefined}
onUseDemo={handleUseDemo}
/>
</div>
)
}
const isEmptyTree = persons.length === 0
&& !!tree.treeContext.treeId
&& tree.canEditCurrentTree
&& tree.treeContext.accessMode !== 'demo'
return (
<div className="app">
{/* Bannière de bienvenue (premier utilisateur, mode démo) */}
{treeWelcomeVisible && (
<div className="tree-created-prompt" role="status">
<strong>{t('Votre arbre est créé.')}</strong>
{t("Ajoutez un proche pour commencer, comme votre mère, votre père ou un frère. Il suffit d'un prénom.")}
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => setTreeWelcomeVisible(false)} aria-label={t('Fermer')}>
{t('Fermer')}
</button>
</div>
)}
{treeImportError && (
<div className="tree-created-prompt tree-created-prompt--error" role="alert">
<strong>{t("Votre arbre est créé, mais l'import n'a pas abouti.")}</strong>
{` ${treeImportError} `}
{t("Vous pouvez réessayer depuis les paramètres de l'arbre.")}
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => setTreeImportError('')} aria-label={t('Fermer')}>
{t('Fermer')}
</button>
</div>
)}
{/* Bouton premier ajout (arbre vide) */}
{isEmptyTree && (
<button
type="button"
className="empty-tree-add"
onClick={() => { editMode.activate(); handleOpenAddPerson() }}
aria-label={t('Ajouter une première personne')}
>
<svg className="empty-tree-add-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
<div className="empty-tree-add-body">
<span className="empty-tree-add-title">{t('Ajouter une personne')}</span>
<span className="empty-tree-add-hint">{t("Commencer l'arbre")}</span>
</div>
<svg className="empty-tree-add-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="6 4 10 8 6 12" />
</svg>
</button>
)}
{/* Recherche Expandable */}
<ExpandableSearch
results={search.results}
hasResults={search.hasResults}
onSearch={search.handleSearch}
onClear={search.clearSearch}
onResultClick={async (person) => {
const fullPerson = persons.find(p => p.id === person.id)
if (!fullPerson) return
trackDemoInteraction('search')
if (!(await chooseSelectedPerson(fullPerson))) return
galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id)
}}
/>
{/* Draft Restore Banner (au reload si draft trouvé) */}
<DraftRestoreBanner
visible={showDraftBanner && !editMode.isActive}
draftTimestamp={editMode.draftTimestamp}
onRestore={handleDraftRestore}
onDiscard={handleDraftDiscard}
/>
{/* Edit Mode Overlay */}
<EditModeOverlay
active={editMode.isActive}
hasDraft={editMode.hasDraft}
draftTimestamp={editMode.draftTimestamp}
role={userRole.isAdmin ? 'admin' : 'contributor'}
onDeactivate={deactivateEditMode}
onSubmit={handleEditSubmit}
/>
{/* Confirmation Modal (quitter mode édition avec draft) */}
<EditConfirmModal
visible={showConfirmModal}
onContinue={handleConfirmContinue}
onSaveAndQuit={handleConfirmSaveAndQuit}
onDiscard={handleConfirmDiscard}
/>
{/* Slide-in Ajouter Personne */}
<AddPersonPanel
visible={addPersonPanelVisible && editMode.isActive}
persons={persons}
loading={addPersonLoading}
error={addPersonError}
onSubmit={handleAddPersonSubmit}
onClose={handleCloseAddPerson}
/>
{/* Modal Session Contribution (contributeurs) */}
<ContributionSessionModal
visible={showContribModal}
changes={contribChanges}
recap={(() => {
const d = editMode.restoreDraft()
return {
added: (d.addedPersons || []).length + (d.addedAnnotations || []).length,
modified: Object.keys(d.modifiedPersons || {}).length + (d.modifiedAnnotations || []).length,
deleted: (d.deletedPersons || []).length + (d.deletedAnnotations || []).length,
}
})()}
loading={contribSubmitLoading}
error={contribSubmitError}
askName={!auth.userAuth.user?.name}
onSubmit={handleContribSessionSubmit}
onCancel={() => setShowContribModal(false)}
/>
{/* Toast succès contribution */}
<FamilyWelcome
visible={familyWelcomeVisible && !souvenirPickerVisible}
treeName={tree.treeContext.treeName}
onAddSouvenir={handleOpenSouvenirPicker}
onLook={closeFamilyWelcome}
/>
<SouvenirPicker
visible={souvenirPickerVisible}
persons={persons}
rootPersonId={tree.treeContext.rootPersonId}
onPick={handleSouvenirPick}
onAddPerson={handleSouvenirPickerAddPerson}
onClose={() => setSouvenirPickerVisible(false)}
/>
{draftNotice && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
<div className="contrib-success-toast" role="status">
{t("Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.")}
</div>
)}
{/* Navbar contextuelle */}
<ContextualNavbar
userRole={userRole}
pendingContributions={contrib.contribState.sessions?.length || 0}
editModeActive={editMode.isActive}
trees={auth.accountTrees}
activeTreeId={tree.treeContext.treeId}
onEditClick={handleEditToggle}
onAddPersonClick={handleOpenAddPerson}
onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined}
helpUrl={HELP_URL}
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
onTreeSelect={handleOpenTreeFromAccount}
onCreateTree={handleOpenTreeWizard}
onProfileClick={handleUseAccount}
onAccountClick={() => setAccountPanelVisible(true)}
onSettingsClick={() => setAdminPanelVisible(true)}
onLogout={handleLogout}
userDisplayName={accountDisplayName}
userEmail={accountEmail}
userAvatarPhoto={accountAvatarPhoto}
linkedPersonName={linkedSelfPerson ? `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() : ''}
activeTreeName={activeAccountTreeName}
annotationTools={editMode.isActive ? {
activeTool: annot.activeTool,
onToolChange: annot.setActiveTool,
currentStyle: annot.currentStyle,
onStyleChange: annot.setCurrentStyle,
selectedAnnotationId: annot.selectedAnnotationId,
selectedSticker: annot.selectedSticker,
onStickerSelect: annot.setSelectedSticker,
onDelete: annot.deleteSelectedAnnotation,
isDragging: annot.isDragging,
isOverTrash: annot.isOverTrash,
onPhotoFileSelected: handleAnnotationPhotoFile,
} : null}
/>
<Galaxy
onPersonSelect={handlePersonSelect}
onMediaSelect={handleMediaSelectFresh}
selectedPersonId={tree.selectedPerson?.id || null}
searchHighlightIds={search.matchedPersonIds}
graphRevision={tree.graphRevision}
annotationHandlers={editMode.isActive ? annot : null}
transformReadRef={galaxyTransformReadRef}
zoomApiRef={galaxyZoomApiRef}
focusPersonId={tree.treeContext.rootPersonId}
assetRevision={tree.assetRevision}
photoHint={tree.canEditCurrentTree}
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
onClose={() => chooseSelectedPerson(null)}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
editMode={editMode.isActive}
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
onManageMedia={media.handleOpenMediaManager}
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined}
treeId={tree.treeContext.treeId || null}
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
avatarActionLoading={avatarActionState.loading}
avatarActionError={avatarActionState.error}
isAdmin={userRole.isAdmin}
onSave={handlePersonSave}
onDelete={handlePersonDelete}
onAddRelation={handleAddRelation}
onRemoveRelation={handleRemoveRelation}
onSetParentage={handleSetParentage}
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
relationActionLoading={relationFeedback.loading}
relationActionSuccess={relationFeedback.success}
relationActionError={relationFeedback.error}
siblingPlace={siblingPlace}
onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
onUploadMomentFile={tree.canEditCurrentTree || tree.canSubmitContribution ? media.uploadMomentFile : undefined}
onMomentSaved={media.refreshMomentMedia}
/>
)}
{/* Crayon de modification masqué en démo : rien n'y est enregistré */}
{tree.selectedMedia && (
<MediaViewer
media={tree.selectedMedia.media}
person={tree.selectedMedia.person}
onClose={() => tree.setSelectedMedia(null)}
canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)}
onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)}
onSave={async (payload) => {
const { person, media: current } = tree.selectedMedia
await media.handleUpdatePersonMedia(person.id, current.id, payload)
// Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié
tree.setSelectedMedia({
person,
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
})
}}
/>
)}
<MediaManagerPanel
visible={media.mediaManagerState.visible}
person={tree.selectedPerson}
medias={media.selectedPersonMedias}
loading={media.mediaManagerState.loading}
errorMessage={media.mediaManagerState.error}
notice={media.mediaManagerState.notice}
canManage={tree.canEditCurrentTree}
onClose={media.handleCloseMediaManager}
onUpload={media.handleUploadPersonMedia}
onMove={media.handleMovePersonMedia}
onDelete={media.handleDeletePersonMedia}
/>
<ContributionPanel
visible={contrib.contribState.visible}
treeId={tree.treeContext.treeId}
canModerate={tree.canEditCurrentTree}
loading={contrib.contribState.loading}
errorMessage={contrib.contribState.error}
inviteShareUrl={contribInviteState.shareUrl}
inviteKnownPasswords={contribInviteState.knownPasswords}
inviteSaving={contribInviteState.loading}
inviteError={contribInviteState.error}
inviteMessage={contribInviteState.message}
sessions={contrib.contribState.sessions}
reviewingSessionId={contrib.contribState.reviewingSessionId}
onClose={contrib.handleCloseContributionPanel}
onRefresh={contrib.refreshContributionSessions}
onRotatePasswords={handleContributionPasswordRotate}
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
/>
<AccountPanel
visible={accountPanelVisible}
userDisplayName={accountDisplayName}
userEmail={accountEmail}
linkedPerson={linkedSelfPerson}
selectedPersonId={linkedSelfPersonId}
people={persons}
onSelectPerson={handleSelectSelfPerson}
onManageAccount={() => handleUseAccount('login')}
onClose={() => setAccountPanelVisible(false)}
/>
<AdminPanel
visible={adminPanelVisible}
treeId={tree.treeContext.treeId}
authToken={tree.treeContext.accessToken}
isAdmin={userRole.isAdmin}
onTreeDeleted={handleTreeDeleted}
onGedcomImported={handleGedcomImported}
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
onClose={() => setAdminPanelVisible(false)}
/>
<PhotoStartPanel
visible={photoStart.visible}
personsImported={photoStart.personsImported}
photoRefs={photoStart.refs}
mediaRefs={photoStart.mediaRefs}
bundledFiles={photoStart.bundledFiles}
faces={photoStart.faces}
onUploadOne={handlePhotoStartUploadOne}
onBulkPlace={handlePhotoStartBulk}
onBulkMedia={handlePhotoStartMedia}
onRefsChange={(refs) => savePhotoRefs(tree.treeContext.treeId, refs)}
onMediaRefsChange={(refs) => saveMediaRefs(tree.treeContext.treeId, refs)}
onClose={handlePhotoStartClose}
/>
{/* Annotation Toolbar (visible uniquement en mode édition) */}
{editMode.isActive && (
<AnnotationToolbar
activeTool={annot.activeTool}
onToolChange={annot.setActiveTool}
currentStyle={annot.currentStyle}
onStyleChange={annot.setCurrentStyle}
selectedAnnotationId={annot.selectedAnnotationId}
selectedAnnotation={annot.selectedAnnotation}
selectedSticker={annot.selectedSticker}
onStickerSelect={annot.setSelectedSticker}
onDelete={annot.deleteSelectedAnnotation}
onResizeSelected={annot.resizeSelected}
onPhotoFileSelected={handleAnnotationPhotoFile}
isDragging={annot.isDragging}
isOverTrash={annot.isOverTrash}
/>
)}
{/* Text Input Overlay (saisie texte sur canvas) */}
<TextInputOverlay
textInputState={annot.textInputState}
currentStyle={annot.currentStyle}
onCommit={annot.commitTextInput}
onCancel={annot.cancelTextInput}
/>
{/* Onboarding premier arbre */}
<TreeCreationWizard
visible={treeWizardVisible}
loading={treeWizardLoading}
defaultFirstName={accountDisplayName}
onClose={() => setTreeWizardVisible(false)}
onSubmit={handleCreateTreeFromWizard}
/>
</div>
)
}
export default App