Files
parenthese/frontend/src/hooks/useTreeAccess.js
T
IliesAllaliandClaude Opus 5.5 9e810fd844 Famille arrivée par le lien : accueil, ajout guidé, mode d'emploi
Replays du 24/09 : personne ne trouvait comment ajouter. Pour qu'une
grand-mère s'en sorte seule depuis un lien :

- arbre ouvert zoomé sur la personne de départ quand il est trop grand
  pour être lisible en entier, sans fiche ouverte par-dessus
- carte d'accueil de deux phrases, une seule fois par arbre
- barre du bas en toutes lettres : Ajouter un souvenir, Ajouter une
  personne, Plus (dessin, mode d'emploi, compte)
- « Pour qui ? » : recherche par prénom avec portraits, puis les souvenirs
  de la personne ; panneau simplifié pour la famille
- une personne ajoutée part tout de suite à l'envoi (prénom, un mot)
- fiche ouverte sur Souvenirs avec le bouton, dates en toutes lettres
- bandeau d'envoi masqué tant qu'il n'y a rien à envoyer
- portraits de l'arbre perdus après un rechargement sans nouveau
  placement : horodatage stable et images rechargées à chaque données
- page parenthese.io/aide/ avec captures de l'arbre de démo, liée depuis
  l'accueil, le menu Plus et le message d'invitation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 18:51:45 +02:00

218 lines
7.5 KiB
JavaScript

import { useCallback, useMemo, useRef, useState } from 'react'
import { API_BASE_URL } from '../api/client'
import { fetchTreeGraph, unlockTreeAccess } from '../api/treeApi'
import { loadGraphData, persons as graphPersons, resetToDemoData } from '../data/mockData'
import { getShareErrorMessage } from '../utils/errorMessages'
export const TREE_ID_FROM_ENV = (import.meta.env.VITE_TREE_ID || '').trim()
const TOKEN_STORAGE_PREFIX = 'tree_access_token_'
// Mot de passe de partage unique (25/09/2026) : tout accès partagé peut proposer.
// Les jetons et accès « visitor » d'avant valent contributeur.
function normalizeRole(role) {
const value = String(role || '').trim().toLowerCase()
return value === 'visitor' ? 'contributor' : value
}
export function getTreeTokenStorageKey(treeId) {
return `${TOKEN_STORAGE_PREFIX}${treeId}`
}
// Rôle d'un jeton d'accès partagé : toujours contributeur depuis le mot de passe unique (le serveur tranche)
export function readTreeAccessRole() {
return 'contributor'
}
export function getSlugFromPathname() {
const match = window.location.pathname.match(/^\/arbre\/([^/]+)$/)
return match ? decodeURIComponent(match[1]) : null
}
export function useTreeAccess() {
const [treeContext, setTreeContext] = useState({
treeId: '',
treeName: '',
treeDescription: '',
treeOwnerName: '',
treeOwnerEmail: '',
role: '',
accessMode: '',
rootPersonId: null,
accessToken: '',
})
const [bootState, setBootState] = useState('loading')
const [gateLoading, setGateLoading] = useState(false)
const [gateError, setGateError] = useState('')
const [selectedPerson, setSelectedPerson] = useState(null)
const [selectedMedia, setSelectedMedia] = useState(null)
const [wizardState, setWizardState] = useState({ visible: false, loading: false, error: '' })
const [graphRevision, setGraphRevision] = useState(0)
// Change à chaque chargement des données, même sans nouveau placement : l'arbre y charge les images manquantes
const [assetRevision, setAssetRevision] = useState(0)
// Horodatage des portraits stable pendant la session : un rechargement ne force pas à tout retélécharger.
// Il ne change que lorsqu'un portrait change (envoi d'une nouvelle photo).
const avatarCacheBustRef = useRef(Date.now())
const canEditCurrentTree = useMemo(() => {
const role = normalizeRole(treeContext.role)
return role === 'owner' || role === 'admin'
}, [treeContext.role])
const canSubmitContribution = useMemo(() => {
const role = normalizeRole(treeContext.role)
return Boolean(role) && role !== 'visitor'
}, [treeContext.role])
const openTreeGraph = async (treeId, token, options = {}) => {
const graphPayload = await fetchTreeGraph(treeId, token)
loadGraphData(graphPayload.graph, {
authToken: token,
apiBaseUrl: API_BASE_URL,
avatarCacheBust: options.avatarCacheBust ?? avatarCacheBustRef.current,
})
if (options.avatarCacheBust) avatarCacheBustRef.current = options.avatarCacheBust
setAssetRevision((current) => current + 1)
if (options.relayout !== false) {
setGraphRevision((current) => current + 1)
}
const preferredRootPersonId = options.rootPersonId ?? graphPayload.rootPersonId ?? null
const rootPerson = preferredRootPersonId
? graphPersons.find((person) => String(person.id) === String(preferredRootPersonId)) || null
: null
// Par le lien de partage, la famille arrive sur l'arbre et sa carte d'accueil, pas sur une fiche ouverte
const openedByShareLink = (options.accessMode ?? '') === 'share'
setSelectedPerson(openedByShareLink ? null : rootPerson)
setSelectedMedia(null)
setTreeContext((current) => ({
treeId,
treeName: options.treeName ?? current.treeName,
treeDescription: options.treeDescription ?? current.treeDescription,
treeOwnerName: options.treeOwnerName ?? current.treeOwnerName,
treeOwnerEmail: options.treeOwnerEmail ?? current.treeOwnerEmail,
role: normalizeRole(options.role ?? current.role),
accessMode: options.accessMode ?? current.accessMode,
rootPersonId: preferredRootPersonId,
accessToken: token,
}))
const effectiveRole = normalizeRole(options.role)
const shouldPromptWizard =
Boolean(options.promptWizard) &&
(effectiveRole === 'owner' || effectiveRole === 'admin') &&
Array.isArray(graphPayload?.graph?.persons) &&
graphPayload.graph.persons.length === 0
if (shouldPromptWizard) {
setWizardState({ visible: true, loading: false, error: '' })
} else {
setWizardState((current) => ({ ...current, visible: false, loading: false, error: '' }))
}
setBootState('ready')
return graphPayload
}
const refreshCurrentTreeGraph = async (options = {}) => {
await openTreeGraph(treeContext.treeId, treeContext.accessToken, {
treeName: treeContext.treeName,
treeDescription: treeContext.treeDescription,
treeOwnerName: treeContext.treeOwnerName,
treeOwnerEmail: treeContext.treeOwnerEmail,
role: treeContext.role,
accessMode: treeContext.accessMode,
rootPersonId: treeContext.rootPersonId,
promptWizard: false,
relayout: options.relayout !== false,
avatarCacheBust: options.avatarCacheBust,
})
}
const bumpGraphRevision = useCallback(() => {
setGraphRevision((current) => current + 1)
}, [])
const handleUnlock = async (treeId, password, options = {}) => {
setGateLoading(true)
setGateError('')
try {
const access = await unlockTreeAccess(treeId, password, options.userToken || '')
localStorage.setItem(getTreeTokenStorageKey(treeId), access.token)
await openTreeGraph(treeId, access.token, {
treeName: options.treeName ?? treeContext.treeName,
treeDescription: options.treeDescription ?? treeContext.treeDescription,
treeOwnerName: options.treeOwnerName ?? treeContext.treeOwnerName,
treeOwnerEmail: options.treeOwnerEmail ?? treeContext.treeOwnerEmail,
role: access.role,
accessMode: 'share',
promptWizard: false,
})
return access
} catch (error) {
setGateError(getShareErrorMessage(error))
return null
} finally {
setGateLoading(false)
}
}
const handleMediaSelect = (media, person) => {
setSelectedMedia({ media, person })
}
const setWizardVisibility = (visible, error = '') => {
setWizardState((current) => ({
...current,
visible,
error,
}))
}
const handleSkipWizard = () => {
setWizardState({ visible: false, loading: false, error: '' })
}
const handleUseDemo = () => {
resetToDemoData()
setGraphRevision((current) => current + 1)
setSelectedPerson(null)
setSelectedMedia(null)
setGateError('')
setWizardState({ visible: false, loading: false, error: '' })
setTreeContext({
treeId: '',
treeName: '',
treeDescription: '',
treeOwnerName: '',
treeOwnerEmail: '',
role: '',
accessMode: 'demo',
rootPersonId: null,
accessToken: '',
})
setBootState('ready')
}
return {
treeContext, setTreeContext,
bootState, setBootState,
assetRevision,
gateLoading, gateError, setGateError,
selectedPerson, setSelectedPerson,
selectedMedia, setSelectedMedia,
wizardState, setWizardState,
graphRevision,
bumpGraphRevision,
canEditCurrentTree,
canSubmitContribution,
openTreeGraph,
refreshCurrentTreeGraph,
handleUnlock,
handleMediaSelect,
setWizardVisibility,
handleSkipWizard,
handleUseDemo,
}
}