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 { 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 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, deleteParentChildLink, deletePerson, deleteUnion, createPerson, createTree, createUnion, batchAnnotations, deletePersonAvatar, getTree, importGedcom, linkTreeAccess, updateTree, rotateTreePasswords, fetchSharePassword, rememberSharePassword, resolveTreeBySlug, 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 { 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 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, birthDate: person.birthDate || null, deathDate: person.deathDate || null, }, }) 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: 'biologique' } }) } else if (rel.type === 'parent') { changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: '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' }, }) } } 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, '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, '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') && source.isAlive === true) after.deathDate = null 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: [] }) 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) 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]) const openPhotoStart = useCallback(({ personsImported = null, refs = null, mediaRefs = null, treeId = null } = {}) => { photoStartDirtyRef.current = false setPhotoStart({ visible: true, personsImported, refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId), mediaRefs: mediaRefs ?? loadMediaRefs(treeId || tree.treeContext.treeId), faces: computeFirstFaces(), }) }, [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, }) const handlePersonSelect = useCallback((personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike if (personId === null || personId === undefined) { tree.setSelectedPerson(null) return } const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null tree.setSelectedPerson(freshPerson) trackTreeInteraction('text') trackDemoInteraction('person') }, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps const handleMediaSelectFresh = useCallback((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 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) trackDemoInteraction('edit') editMode.toggle() } }, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps // Actions de la modal de confirmation const handleConfirmContinue = useCallback(() => { setShowConfirmModal(false) }, []) const handleConfirmSaveAndQuit = useCallback(() => { editMode.saveDraft() editMode.deactivate() setShowConfirmModal(false) }, [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((person) => { setSouvenirPickerVisible(false) tree.setSelectedPerson(person) setPendingMediaPersonId(person.id) }, [tree.setSelectedPerson]) // 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 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, 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: 'biologique', displayOrder: filiations.length + 1, }) } else if (rel.type === 'child') { filiations.push({ id: makeLocalId('local-filiation'), childId: personId, parentId: rel.personId, unionId: null, parentageType: '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, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), 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 })) 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, birthDate: formData.birthDate, deathDate: formData.deathDate, }, 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: 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'parent') { await createParentChildLink( tree.treeContext.treeId, { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: '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) const [draftNotice, setDraftNotice] = useState('') // 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 () => { 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, '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, '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') && rawChanges.isAlive === true) payload.deathDate = null 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, '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, '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') && changes.isAlive === true) { localPerson.deathYear = null } 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, '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, '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') && changes.isAlive === true) payload.deathDate = null 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: '' }) } catch (error) { setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) }) } }, [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 // 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 => demo by default // - 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.handleUseDemo() 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 = '' try { const content = (await readGedcomFile(payload.gedcomFile)).text imported = content.trim() ? await importGedcom(createdTree.id, content, token) : null if (!imported) importError = t('Le fichier GEDCOM est vide.') } catch (error) { importError = 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 }) } 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() } // 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) => { 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 }) }, [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.') const prepared = await prepareImageForUpload(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, file, caption, source } = matches[i] try { if (!accessToken || !tree.treeContext.treeId) break 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 (
) } if (tree.bootState === 'need-access') { return (