mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +00:00
À l'étape du dossier de photos, les fichiers cités par le logiciel en plus des portraits (actes, registres, photos de famille) sont retrouvés dans le même dossier et ajoutés aux souvenirs de chaque personne, avec le titre et la source venus du GEDCOM. Un seul bouton « Tout ajouter » ; les fichiers restants sont gardés pour rouvrir l'étape depuis les paramètres. Pour #59. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3085 lines
118 KiB
React
3085 lines
118 KiB
React
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 { 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 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 (
|
|
<div className="app app-loading" />
|
|
)
|
|
}
|
|
|
|
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}
|
|
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" 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" 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={(person) => {
|
|
const fullPerson = persons.find(p => p.id === person.id)
|
|
if (!fullPerson) return
|
|
trackDemoInteraction('search')
|
|
tree.setSelectedPerson(fullPerson)
|
|
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={editMode.deactivate}
|
|
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 ? handleOpenSouvenirPicker : undefined}
|
|
helpUrl={isFamilyVisitor ? 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={() => tree.setSelectedPerson(null)}
|
|
onMediaSelect={handleMediaSelectFresh}
|
|
editMode={editMode.isActive}
|
|
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
|
|
onManageMedia={media.handleOpenMediaManager}
|
|
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
|
|
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : 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}
|
|
relationActionLoading={relationFeedback.loading}
|
|
relationActionSuccess={relationFeedback.success}
|
|
relationActionError={relationFeedback.error}
|
|
siblingPlace={siblingPlace}
|
|
onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
|
|
/>
|
|
)}
|
|
{tree.selectedMedia && (
|
|
<MediaViewer
|
|
media={tree.selectedMedia.media}
|
|
person={tree.selectedMedia.person}
|
|
onClose={() => tree.setSelectedMedia(null)}
|
|
/>
|
|
)}
|
|
<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}
|
|
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
|
|
|
|
|
|
|
|
|