diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 32f39b7..c7daaf6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -86,6 +86,9 @@ import './App.css' import './styles/pz.css' import { setBooting } from './bootSignal.js' import { saveContributorName } from './utils/contributorName' +import { ghostsFromChanges, hasGhosts, mergeGhosts } from './utils/ghosts' +import ProposalCard from './components/ProposalCard' +import { summarizeChanges } from './utils/proposalSummary' function yearToIsoDate(value) { if (value === null || value === undefined || value === '') { @@ -688,6 +691,7 @@ function App() { }, [auth.accountTrees, tree.treeContext.treeId, tree.treeContext.treeName]) const isDemoMode = tree.treeContext.accessMode === 'demo' + const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode // Recherche de personnes avec scoring @@ -699,6 +703,56 @@ function App() { canEdit: canEditInCurrentMode, }) + // Fantômes (1.5.1) : ce qui est proposé et pas encore dans l'arbre, dessiné en transparence à sa place. + // Qui gère l'arbre voit les propositions en attente ; la famille voit son brouillon et ce qu'elle a envoyé. + const ghostTreeId = tree.treeContext.treeId + const ghosts = useMemo(() => { + if (!ghostTreeId || isDemoMode) return null + const graph = { unions } + const list = [] + if (userRole.isAdmin) { + for (const session of contrib.contribState.sessions || []) { + if (session.status !== 'pending') continue + list.push(ghostsFromChanges(session.changes, { + source: 'pending', sessionId: session.id, label: session.submittedByLabel, createdAt: session.createdAt, + summary: summarizeChanges(session.changes), + }, graph)) + } + } else if (tree.canSubmitContribution) { + for (const proposal of contrib.sentProposals || []) { + list.push(ghostsFromChanges(proposal.changes, { + source: 'sent', sessionId: proposal.sessionId, label: proposal.label, createdAt: proposal.createdAt, + summary: summarizeChanges(proposal.changes), + }, graph)) + } + if (editMode.hasDraft) { + const draftChanges = buildContributionChangesFromDraft(editMode.restoreDraft()) + list.push(ghostsFromChanges(draftChanges, { source: 'draft', key: 'brouillon', summary: summarizeChanges(draftChanges) }, graph)) + } + } + const merged = mergeGhosts(list) + // Photo posée sur l'arbre : l'adresse se refait depuis son chemin, seulement pour une photo de cet arbre + const photoPrefix = `/trees/${ghostTreeId}/annotation-photos/` + merged.annotations = merged.annotations.map((ann) => { + if (ann.type !== 'photo') return ann + try { + const parsed = JSON.parse(ann.content) + const ownPhoto = typeof parsed.photoPath === 'string' && parsed.photoPath.startsWith(photoPrefix) + && /^[A-Za-z0-9-]+$/.test(parsed.photoPath.slice(photoPrefix.length)) + if (!ownPhoto) return null + return { ...ann, content: JSON.stringify({ ...parsed, url: buildMediaUrl(parsed.photoPath, import.meta.env.VITE_API_BASE_URL || '/api') }) } + } catch { + return null + } + }).filter(Boolean) + return hasGhosts(merged) ? merged : null + }, [ghostTreeId, isDemoMode, userRole.isAdmin, tree.canSubmitContribution, contrib.contribState.sessions, contrib.sentProposals, editMode.hasDraft, editMode.draftRevision, tree.graphRevision]) // eslint-disable-line react-hooks/exhaustive-deps + const [selectedGhost, setSelectedGhost] = useState(null) + // Une proposition relue (ou un brouillon envoyé) emporte son fantôme : la carte ouverte se ferme + useEffect(() => { + if (selectedGhost && !ghosts?.persons.some((p) => p.id === selectedGhost.id)) setSelectedGhost(null) + }, [ghosts, selectedGhost]) + const localEntityCounterRef = useRef(0) const makeLocalId = useCallback((prefix) => { localEntityCounterRef.current += 1 @@ -895,9 +949,18 @@ function App() { const handlePersonSelect = useCallback(async (personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike if (personId === null || personId === undefined) { + setSelectedGhost(null) await chooseSelectedPerson(null) return } + // Personne proposée : sa carte de proposition, pas une fiche (elle n'existe pas encore dans l'arbre) + if (personLike && typeof personLike === 'object' && personLike.ghost) { + if (!(await leaveSelectedPerson())) return + await chooseSelectedPerson(null) + setSelectedGhost(personLike) + return + } + setSelectedGhost(null) const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null if (!(await leaveSelectedPerson())) return @@ -1534,6 +1597,16 @@ function App() { await contrib.handleOpenContributionPanel() }, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps + // Accepter ou refuser une proposition depuis son fantôme. Une proposition se relit en entier ; « Voir le + // détail » ouvre le panneau pour trier changement par changement. + const reviewFromTree = (sessionId) => ({ + busy: contrib.contribState.reviewingSessionId === sessionId, + error: contrib.contribState.error, + onAccept: () => contrib.handleReviewContributionSession(sessionId, { decision: 'approved' }), + onReject: () => contrib.handleReviewContributionSession(sessionId, { decision: 'rejected' }), + onOpenDetails: () => { setSelectedGhost(null); handleOpenContributionPanel() }, + }) + const handleContributionPasswordRemember = useCallback(async ({ password }) => { const accessToken = auth.userAuth.token || tree.treeContext.accessToken if (!tree.treeContext.treeId || !accessToken) { @@ -3265,8 +3338,17 @@ function App() { annotationViewRef={annotationViewRef} onAnnotationsAnchored={persistAnnotationAnchors} selfPersonId={linkedSelfPersonId || null} + ghosts={ghosts} /> + {selectedGhost && !tree.selectedPerson && ( + setSelectedGhost(null)} + /> + )} {tree.selectedPerson && ( node.x + node.width / 2 const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches -const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null }) => { +const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null }) => { const canvasRef = useRef(null) const layoutDataRef = useRef(null) // Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par @@ -56,6 +57,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const activeTabRef = useRef(null) const familyTabsRef = useRef(familyTabs) useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs]) + // Propositions pas encore relues (utils/ghosts.js) : placées et dessinées en transparence, jamais dans les données + const ghostsRef = useRef(ghosts) + useEffect(() => { ghostsRef.current = ghosts; needsRedrawRef.current = true }, [ghosts]) const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored) useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored]) const startRefRef = useRef(focusPersonId) @@ -216,7 +220,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas) const layoutForOrder = useCallback((group, order, base) => { const tab = activeTabRef.current - const src = tab?.personIds ? subgraphOf(tab.personIds) : { persons: graphPersons, unions: graphUnions, filiations: graphFiliations } + const tabSrc = tab?.personIds ? subgraphOf(tab.personIds) : null + const src = withGhosts(tabSrc || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghostsRef.current, !tabSrc) const graph = { persons: src.persons, unions: src.unions, @@ -492,7 +497,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste // là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps ») const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set() - const graph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null + const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null + const graph = hasGhosts(ghosts) + ? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph) + : tabGraph // Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le // placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut) const legacy = annotationData.some((a) => !readAnchor(a)) @@ -653,7 +661,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig pendingFocusRef.current = null requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id)) } - }, [graphRevision, activeKey, familyTabs, selfPersonId]) + }, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts]) // Sync selectedPersonId useEffect(() => { @@ -929,6 +937,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig entrance, entranceActive, elapsed, view, liftScales: liftScalesRef.current, + ghostPatches: ghostsRef.current?.patches || null, }) drawRenvoiPills(ctx) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus. @@ -949,7 +958,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (entranceAlpha > 0) { drawAnnotations( ctx, - editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations, + [ + ...(editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations), + // Annotations proposées : dessinées, jamais touchables (hors de `shown`) + ...placeAnnotations(ghostsRef.current?.annotations || [], annotationCenters), + ], annotationHandlers?.selectedAnnotationId ?? null, annotationHandlers?.drawingPathRef?.current ?? null, stickerPreview, diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index dd75556..176fa23 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -20,6 +20,7 @@ import { isYearOnly } from '../utils/dateInput.js' import DateField from './DateField.jsx' import { runOnce } from '../utils/saveOnLeave.js' import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js' +import { ProposalBanner } from './ProposalCard.jsx' // Aucune modification en cours (référence stable) const NO_EDITS = Object.freeze({}) @@ -66,6 +67,9 @@ const PersonCard = ({ onRemoveRelation, onSetParentage, pendingParentage = null, + // Modification proposée et pas encore relue (fantôme, 1.5.1) : { fields, deleted, ghost }, et sa relecture + proposal = null, + proposalReview = null, relationActionLoading = false, relationActionSuccess = '', relationActionError = '', @@ -785,6 +789,7 @@ const PersonCard = ({ )} + {!editMode && proposal && } {avatarActionError &&
{avatarActionError}
} diff --git a/frontend/src/components/ProposalCard.css b/frontend/src/components/ProposalCard.css new file mode 100644 index 0000000..172cba8 --- /dev/null +++ b/frontend/src/components/ProposalCard.css @@ -0,0 +1,87 @@ +/* Proposition pas encore relue (1.5.1) : même place et même cadre que la fiche, bordure en pointillés */ +.proposal-card { + border-style: dashed; + border-color: var(--pz-a300); +} + +.proposal-photo { + background: var(--pz-a50); + outline: 1.5px dashed var(--pz-a500); + outline-offset: 3px; +} + +.proposal-eyebrow { + color: var(--pz-a700); +} + +.proposal-years { + margin: 2px 0 0; + color: var(--pz-muted); + font-variant-numeric: tabular-nums; +} + +.proposal-body { + display: flex; + flex-direction: column; + gap: 10px; +} + +.proposal-who, +.proposal-summary, +.proposal-change { + margin: 0; + color: var(--pz-ink-2); + font-size: 14px; + line-height: 1.45; +} + +.proposal-summary { + color: var(--pz-muted); +} + +.proposal-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.proposal-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.proposal-details { + margin-left: auto; +} + +.proposal-error { + margin: 0; +} + +/* Fiche existante dont une modification est proposée */ +.proposal-banner { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border: 1px dashed var(--pz-a300); + border-radius: 12px; + background: var(--pz-a50); +} + +.proposal-fields { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; + color: var(--pz-ink); +} + +.proposal-field { + color: var(--pz-muted); + margin-right: 4px; +} diff --git a/frontend/src/components/ProposalCard.jsx b/frontend/src/components/ProposalCard.jsx new file mode 100644 index 0000000..3288eea --- /dev/null +++ b/frontend/src/components/ProposalCard.jsx @@ -0,0 +1,126 @@ +import { X } from 'lucide-react' + +import { t, dateLocale } from '../i18n/index.js' +import { firstNameLabel } from '../utils/personName.js' +import PzBusy from './PzBusy' +import './ProposalCard.css' + +// Proposition pas encore relue, ouverte depuis son fantôme sur l'arbre (1.5.1). Qui gère l'arbre l'accepte ou la +// refuse sur place ; la famille y lit qu'elle attend. Une proposition se relit en entier : « Voir le détail » +// ouvre le panneau des propositions pour trier changement par changement. + +const FIELD_LABELS = { + firstName: t('Prénom'), + lastName: t('Nom'), + birthName: t('Nom de naissance'), + nickname: t('Surnom'), + sex: t('Sexe'), + birthDate: t('Naissance'), + deathDate: t('Décès'), + birthPlace: t('Lieu de naissance'), + deathPlace: t('Lieu de décès'), + profession: t('Profession'), + region: t('Région'), + nationality: t('Nationalité'), + notes: t('Note'), + isDeceased: t('Décédé(e)'), + toVerify: t('À vérifier'), +} + +function formatValue(key, value) { + if (value === null || value === undefined || value === '') return t('vide') + if (typeof value === 'boolean') return value ? t('oui') : t('non') + if ((key === 'birthDate' || key === 'deathDate') && /^\d{4}-\d{2}-\d{2}/.test(String(value))) return String(value).slice(0, 4) + return String(value) +} + +function whoAndWhen(ghost) { + if (ghost?.source === 'draft') return t('Pas encore envoyé : il part avec « Envoyer »') + if (ghost?.source === 'sent') return t('Envoyé, en attente de relecture par la personne qui gère l’arbre') + const date = ghost?.createdAt ? new Date(ghost.createdAt) : null + const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(dateLocale, { day: 'numeric', month: 'long' }) : '' + if (ghost?.label && when) return t('Proposé par {name}, le {date}', { name: ghost.label, date: when }) + if (ghost?.label) return t('Proposé par {name}', { name: ghost.label }) + return t('Proposé par la famille') +} + +function ReviewActions({ ghost, review }) { + if (!review || ghost?.source !== 'pending') return null + const { busy, error, onAccept, onReject, onOpenDetails } = review + return ( +
+ {error &&

{error}

} +
+ + + {onOpenDetails && ( + + )} +
+
+ ) +} + +/** + * @param {Object} props + * @param {Object} props.person - la personne proposée (fantôme, `person.ghost` porte la provenance) + * @param {string} props.summary - ce que contient la proposition entière (summarizeChanges) + * @param {Object|null} props.review - { busy, error, onAccept, onReject, onOpenDetails } pour qui gère l'arbre + */ +export default function ProposalCard({ person, summary = '', review = null, onClose }) { + const ghost = person?.ghost + const years = [person?.birthYear, person?.deathYear].filter(Boolean).join(' – ') + const name = [firstNameLabel(person), person?.lastName].filter(Boolean).join(' ') + const initials = `${(person?.firstName || '?')[0]}${(person?.lastName || '')[0] || ''}`.toUpperCase() + return ( +
+ +
+
+
{initials}
+
+
+

{ghost?.source === 'draft' ? t('Brouillon') : ghost?.source === 'sent' ? t('En attente') : t('Proposition')}

+

+ {person?.firstName ? person.firstName : {firstNameLabel(person)}} + {person?.lastName && <>{' '}{person.lastName}} +

+ {years &&

{years}

} +
+
+
+

{whoAndWhen(ghost)}

+ {summary &&

{t('Dans cette proposition : {summary}.', { summary })}

} + +
+
+ ) +} + +/** + * Bandeau d'une fiche existante dont une modification est proposée. + * @param {Object} props.patch - { fields, deleted, ghost } + */ +export function ProposalBanner({ patch, review = null }) { + if (!patch) return null + const entries = Object.entries(patch.fields || {}).filter(([key]) => FIELD_LABELS[key]) + return ( +
+

{whoAndWhen(patch.ghost)}

+ {patch.deleted ? ( +

{t('Supprimer cette personne de l’arbre')}

+ ) : ( +
    + {entries.map(([key, value]) => ( +
  • {FIELD_LABELS[key]} {formatValue(key, value)}
  • + ))} +
+ )} + +
+ ) +} diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index 234d358..ffa5852 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -397,7 +397,7 @@ export function computeLayout(graphData = null, referenceId = null) { // Entrées d'union unifiées (réelles + virtuelles) const allUnionEntries = [ ...unions.map(u => ({ - elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated), + elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated), ghost: Boolean(u.ghost), })), ...virtualUnions.map(vu => ({ elkId: vu.elkId, p1Key: `p-${vu.parentId}`, p2Key: vu.unknownElkId, virtual: true, @@ -592,6 +592,8 @@ export function computeLayout(graphData = null, referenceId = null) { id: `e-${f.id}`, sources: [sourceId], targets: [targetId], _parentageType: f.parentageType, _displayOrder: f.displayOrder, + // Lien proposé, pas encore relu (utils/ghosts.js) : dessiné en pointillés + _ghost: Boolean(f.ghost), }) }) diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index e9488b5..4b9a35b 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -100,6 +100,9 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s // Coins arrondis de rayon fixe (accolade franche, forme A). // ============================================================ export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 } +// Proposé, pas encore relu (utils/ghosts.js) : pointillés terracotta, portrait délavé, étiquette au-dessus +export const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 } +const GHOST_LABELS = { draft: () => t('Brouillon'), sent: () => t('En attente'), pending: () => t('Proposé') } // Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants const BUS_DY = 100 // Accolade : coins arrondis de rayon fixe (forme A, choisie par Patch). En dessous de deux rayons d'écart, @@ -148,7 +151,7 @@ export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) { // Courbes de couple (Bézier) // ============================================================ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) { - coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual }) => { + coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual, ghost }) => { const p1Pos = posMap.get(p1Key) const p2Pos = posMap.get(p2Key) const unionPos = posMap.get(elkId) @@ -177,9 +180,9 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD p1PosKeyed, p2PosKeyed, unionPos, p1R, p2R, nodeRandomData, nodeMap.get(elkId)?._compact ) - ctx.strokeStyle = virtual ? COLORS.linkUnknown : FIL.color + ctx.strokeStyle = ghost ? GHOST.color : virtual ? COLORS.linkUnknown : FIL.color ctx.lineWidth = FIL.width - ctx.setLineDash(virtual ? [4, 4] : []) + ctx.setLineDash(ghost ? GHOST.dash : virtual ? [4, 4] : []) ctx.lineCap = 'round' ctx.globalAlpha = coupleProgress @@ -307,16 +310,21 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr return } // Les enfants d'une même union (même type de filiation) forment une seule fratrie, tracée d'un coup - const key = `${sourceId}|${pType}` - if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, items: [], progress: 0 }) + // Un enfant proposé a sa propre ligne, en pointillés, par-dessus celle de ses frères et sœurs + const ghost = Boolean(edge._ghost || targetNode?._data?.ghost) + const key = `${sourceId}|${pType}|${ghost ? 'g' : ''}` + if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, ghost, items: [], progress: 0 }) const sib = sibships.get(key) sib.items.push({ cx: targetPos.cx, cy: targetPos.cy - childR }) sib.progress += edgeProgress }) - for (const { sourceId, sourcePos, pType, items, progress } of sibships.values()) { + for (const { sourceId, sourcePos, pType, ghost, items, progress } of sibships.values()) { // Adoption : trait plein comme une filiation de naissance (le mode d'adoption se dit dans la fiche) - if (pType === 'inconnu') { + if (ghost) { + ctx.setLineDash(GHOST.dash) + ctx.strokeStyle = GHOST.color + } else if (pType === 'inconnu') { ctx.setLineDash([3, 3]) ctx.strokeStyle = COLORS.linkUnknown } else { @@ -1089,6 +1097,8 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela if (node._type === 'person') { drawPersonNode(ctx, node, pos, nodeProgress, renderState) + if (node._data?.ghost) drawGhostMark(ctx, node, pos, nodeProgress, view) + else if (renderState.ghostPatches?.has(String(node._data?.id))) drawPatchMark(ctx, node, pos, nodeProgress, renderState.ghostPatches.get(String(node._data.id))) } else if (node._type === 'union') { ctx.globalAlpha = nodeProgress ctx.fillStyle = FIL.dot @@ -1128,6 +1138,70 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela }) } +// Personne proposée : portrait délavé, cercle en pointillés, étiquette (« Proposé », « En attente », « Brouillon ») +function drawGhostMark(ctx, node, pos, progress, view) { + const r = nodeRadius(node) + ctx.save() + ctx.globalAlpha = progress + ctx.fillStyle = GHOST.wash + ctx.beginPath() + ctx.arc(pos.cx, pos.cy, r + 1, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = GHOST.color + ctx.lineWidth = 1.6 + ctx.setLineDash(GHOST.dash) + ctx.beginPath() + ctx.arc(pos.cx, pos.cy, r + 5, 0, Math.PI * 2) + ctx.stroke() + ctx.setLineDash([]) + if ((view?.scale ?? 1) >= 0.35) drawGhostTag(ctx, (GHOST_LABELS[node._data.ghost.source] || GHOST_LABELS.pending)(), pos.cx, pos.cy - r - 14) + ctx.restore() +} + +// Personne existante dont une modification est proposée : une pastille en haut à droite du portrait +function drawPatchMark(ctx, node, pos, progress, patch) { + const r = nodeRadius(node) + const angle = -Math.PI / 4 + const x = pos.cx + Math.cos(angle) * (r + 2) + const y = pos.cy + Math.sin(angle) * (r + 2) + ctx.save() + ctx.globalAlpha = progress + ctx.fillStyle = '#FBFAF7' + ctx.beginPath() + ctx.arc(x, y, 8, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = GHOST.color + ctx.beginPath() + ctx.arc(x, y, 5.5, 0, Math.PI * 2) + ctx.fill() + if (patch?.deleted) { + ctx.strokeStyle = '#FBFAF7' + ctx.lineWidth = 1.6 + ctx.beginPath() + ctx.moveTo(x - 2.6, y) + ctx.lineTo(x + 2.6, y) + ctx.stroke() + } + ctx.restore() +} + +function drawGhostTag(ctx, text, cx, cy) { + ctx.font = '500 10px "DM Sans", system-ui, sans-serif' + const w = ctx.measureText(text).width + 12 + const h = 16 + ctx.fillStyle = '#FBFAF7' + ctx.strokeStyle = GHOST.color + ctx.lineWidth = 1 + ctx.beginPath() + ctx.roundRect(cx - w / 2, cy - h / 2, w, h, h / 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = GHOST.color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(text, cx, cy + 0.5) +} + // ============================================================ // Annotations (drawing, sticker, text) // ============================================================ @@ -1148,7 +1222,7 @@ function drawDrawingAnnotation(ctx, ann) { ctx.lineWidth = path.width || ann.style?.brushWidth || 3 ctx.lineCap = 'round' ctx.lineJoin = 'round' - ctx.globalAlpha = ann.style?.opacity ?? 1 + ctx.globalAlpha *= ann.style?.opacity ?? 1 ctx.beginPath() ctx.moveTo(path.points[0].x, path.points[0].y) @@ -1163,7 +1237,7 @@ function drawDrawingAnnotation(ctx, ann) { function drawStickerAnnotation(ctx, ann) { const size = ann.style?.fontSize || 40 ctx.save() - ctx.globalAlpha = ann.style?.opacity ?? 1 + ctx.globalAlpha *= ann.style?.opacity ?? 1 ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -1177,7 +1251,7 @@ function drawTextAnnotation(ctx, ann) { const stabilo = ann.style?.stabilo || false ctx.save() - ctx.globalAlpha = ann.style?.opacity ?? 1 + ctx.globalAlpha *= ann.style?.opacity ?? 1 ctx.font = `400 ${fontSize}px "Newsreader", Georgia, serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -1321,7 +1395,7 @@ export function drawAnnotations( const lift = isBeingDragged ? dragLiftProgress : 0 ctx.save() - ctx.globalAlpha *= entranceAlpha + ctx.globalAlpha *= entranceAlpha * (ann.ghost ? GHOST.annotationAlpha : 1) ctx.translate(ann.x, ann.y) // Lift/fly effect when dragging: scale up + drop shadow, animated via dragLiftProgress diff --git a/frontend/src/hooks/useContributions.js b/frontend/src/hooks/useContributions.js index 253ed96..d768587 100644 --- a/frontend/src/hooks/useContributions.js +++ b/frontend/src/hooks/useContributions.js @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { + fetchContributionStatus, listContributionSessions, submitContributionSession, reviewContributionSession, - batchAnnotations, } from '../api/treeApi' +import { forgetSentProposal, readSentProposals, rememberSentProposal } from '../utils/sentProposals' import { getAccountErrorMessage } from '../utils/errorMessages' import { t } from '../i18n/index.js' @@ -41,6 +42,34 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisible) } }, [canEditCurrentTree, treeId, accessToken]) + // Ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture : dessiné en fantôme « En attente » + // (utils/ghosts.js). Chaque proposition est revérifiée au chargement et au retour sur l'onglet. + const [sentProposals, setSentProposals] = useState([]) + const refreshSentProposals = useCallback(async () => { + if (!treeId || !accessToken || canEditCurrentTree) { setSentProposals([]); return } + const stored = readSentProposals(treeId) + setSentProposals(stored) + const stillPending = [] + for (const proposal of stored) { + try { + const status = await fetchContributionStatus(treeId, proposal.sessionId, accessToken) + if (status === 'pending') stillPending.push(proposal) + else forgetSentProposal(treeId, proposal.sessionId) + } catch (error) { + // Introuvable : relue puis effacée, ou d'un autre arbre. Hors ligne : on la garde + if (error?.status === 404) forgetSentProposal(treeId, proposal.sessionId) + else stillPending.push(proposal) + } + } + setSentProposals(stillPending) + }, [treeId, accessToken, canEditCurrentTree]) + useEffect(() => { + refreshSentProposals() + const onVisible = () => { if (document.visibilityState === 'visible') refreshSentProposals() } + document.addEventListener('visibilitychange', onVisible) + return () => document.removeEventListener('visibilitychange', onVisible) + }, [refreshSentProposals]) + const resetContribState = () => { setContribState({ ...EMPTY_CONTRIB_STATE }) } @@ -121,6 +150,14 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr if (result?.session?.status === 'approved') { await refreshCurrentTreeGraph() + } else if (result?.session?.id && !canEditCurrentTree) { + rememberSentProposal(treeContext.treeId, { + sessionId: result.session.id, + label: payload.submittedByLabel || null, + createdAt: result.session.createdAt || new Date().toISOString(), + changes: payload.changes, + }) + setSentProposals(readSentProposals(treeContext.treeId)) } setContribState((current) => ({ @@ -211,38 +248,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr treeContext.accessToken, ) - // Appliquer côté frontend les annotations approuvées (le backend ne les gère pas) - const session = contribState.sessions.find((s) => s.id === sessionId) - if (session) { - const isAllApproved = reviewPayload.decision === 'approved' - const approvedIds = new Set( - (reviewPayload.changeDecisions || []) - .filter((d) => d.decision === 'approved') - .map((d) => d.changeId), - ) - const approvedAnnotations = (session.changes || []).filter((c) => - c.entityType === 'annotation' && (isAllApproved || approvedIds.has(c.id)), - ) - if (approvedAnnotations.length > 0) { - const batchPayload = { - create: approvedAnnotations - .filter((c) => c.action === 'create') - .map((c) => c.afterJson || c.after), - update: approvedAnnotations - .filter((c) => c.action === 'update') - .map((c) => ({ id: c.entityId, ...(c.afterJson || c.after) })), - delete: approvedAnnotations - .filter((c) => c.action === 'delete') - .map((c) => c.entityId), - } - try { - await batchAnnotations(treeContext.treeId, batchPayload, treeContext.accessToken) - } catch (annErr) { - console.error('Failed to apply approved annotations:', annErr) - } - } - } - + // Les annotations acceptées sont posées par le serveur, dans la même transaction (1.5.1) await refreshCurrentTreeGraph() await refreshContributionSessions() @@ -271,5 +277,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr handleSubmitContributionUpdatePerson, handleSubmitContributionDeletePerson, handleReviewContributionSession, + sentProposals, + refreshSentProposals, } } diff --git a/frontend/src/hooks/useEditMode.js b/frontend/src/hooks/useEditMode.js index d22e8aa..7183053 100644 --- a/frontend/src/hooks/useEditMode.js +++ b/frontend/src/hooks/useEditMode.js @@ -21,6 +21,8 @@ export function useEditMode({ treeId, canEdit }) { const [isActive, setIsActive] = useState(false) const [hasDraft, setHasDraft] = useState(false) const [draftTimestamp, setDraftTimestamp] = useState(null) + // Monte à chaque changement du brouillon : ses fantômes se redessinent (utils/ghosts.js) + const [draftRevision, setDraftRevision] = useState(0) const autoSaveTimerRef = useRef(null) const draftDataRef = useRef({}) @@ -151,6 +153,7 @@ export function useEditMode({ treeId, canEdit }) { } // Considérer qu'il y a désormais un draft actif (permet le bouton "Mettre à jour l'arbre") setHasDraft(true) + setDraftRevision((r) => r + 1) }, []) /** @@ -164,6 +167,7 @@ export function useEditMode({ treeId, canEdit }) { draftDataRef.current = {} setHasDraft(false) setDraftTimestamp(null) + setDraftRevision((r) => r + 1) } catch (err) { console.error('Failed to clear draft:', err) } @@ -180,6 +184,7 @@ export function useEditMode({ treeId, canEdit }) { // État isActive, hasDraft, + draftRevision, draftTimestamp, // Actions diff --git a/frontend/src/i18n/en/contribution.js b/frontend/src/i18n/en/contribution.js index 765a1e8..7864d9b 100644 --- a/frontend/src/i18n/en/contribution.js +++ b/frontend/src/i18n/en/contribution.js @@ -195,4 +195,33 @@ export default { 'Des modifications non enregistrées vous attendent': 'You have unsaved changes waiting', 'Restaurer': 'Restore', 'Ignorer': 'Dismiss', + + // ─── Fantômes : propositions pas encore relues, sur l'arbre (1.5.1) ─── + 'Brouillon': 'Draft', + 'En attente': 'Waiting', + 'Proposé': 'Suggested', + 'Proposition': 'Suggestion', + 'oui': 'yes', + 'non': 'no', + 'Pas encore envoyé : il part avec « Envoyer »': 'Not sent yet: it goes with “Send”', + 'Envoyé, en attente de relecture par la personne qui gère l’arbre': 'Sent, waiting for the person who manages the tree to review it', + 'Proposé par {name}, le {date}': 'Suggested by {name} on {date}', + 'Proposé par {name}': 'Suggested by {name}', + 'Proposé par la famille': 'Suggested by the family', + 'Relecture en cours': 'Reviewing', + 'Voir le détail': 'See details', + 'Proposition : {name}': 'Suggestion: {name}', + 'Dans cette proposition : {summary}.': 'In this suggestion: {summary}.', + 'Modification proposée': 'Suggested change', + 'Supprimer cette personne de l’arbre': 'Remove this person from the tree', + '{n} personne': '{n} person', + '{n} personnes': '{n} people', + '{n} fiche modifiée': '{n} profile edited', + '{n} fiches modifiées': '{n} profiles edited', + '{n} lien': '{n} link', + '{n} liens': '{n} links', + "{n} ajout sur l'arbre": '{n} addition on the tree', + "{n} ajouts sur l'arbre": '{n} additions on the tree', + '{n} autre changement': '{n} other change', + '{n} autres changements': '{n} other changes', } diff --git a/frontend/src/utils/ghosts.js b/frontend/src/utils/ghosts.js new file mode 100644 index 0000000..c3b2b50 --- /dev/null +++ b/frontend/src/utils/ghosts.js @@ -0,0 +1,184 @@ +// Fantômes (1.5.1) : ce qui est proposé mais pas encore dans l'arbre, dessiné en transparence à sa place. +// Trois sources, même format de changements que l'envoi d'une proposition : +// - « draft » : le brouillon de la famille, pas encore envoyé ; +// - « sent » : ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture ; +// - « pending » : les propositions en attente, vues par qui gère l'arbre. +// Les fantômes ne touchent jamais aux données de l'arbre : ils ne s'ajoutent qu'au placement et au dessin. + +function afterOf(change) { + const after = change?.afterJson ?? change?.after + return after && typeof after === 'object' && !Array.isArray(after) ? after : null +} + +function yearOf(isoDate) { + const match = typeof isoDate === 'string' ? isoDate.match(/^(\d{4})/) : null + return match ? Number(match[1]) : null +} + +function unionIdFor(parentId, unions) { + const own = unions.filter((u) => String(u.partner1Id) === String(parentId) || String(u.partner2Id) === String(parentId)) + return own.length === 1 ? String(own[0].id) : null +} + +/** + * Fantômes d'une liste de changements. + * @param {Array} changes - changements d'une proposition ({ entityType, action, entityId, after | afterJson, id }) + * @param {Object} meta - { source, key, sessionId?, label?, createdAt? } ; `key` rend les identifiants uniques + * @param {Object} graph - { unions } de l'arbre, pour rattacher un enfant proposé au couple de son parent + */ +export function ghostsFromChanges(changes = [], meta = {}, graph = {}) { + const key = String(meta.key || meta.sessionId || 'brouillon') + const ghost = { ...meta, key } + const refs = new Map() + const persons = [] + const unions = [] + const filiations = [] + const annotations = [] + const patches = new Map() + const resolve = (id) => (id == null ? null : refs.get(String(id)) || String(id)) + + changes.forEach((change, index) => { + if (change?.entityType !== 'person' || change.action !== 'create') return + const after = afterOf(change) + if (!after) return + const id = `ghost:${key}:${after.ref || change.id || index}` + if (after.ref) refs.set(String(after.ref), id) + persons.push({ + id, + firstName: after.firstName || '', + lastName: after.lastName || '', + birthName: after.birthName || null, + nickname: after.nickname || null, + sex: after.sex || null, + birthDate: after.birthDate || null, + deathDate: after.deathDate || null, + birthYear: yearOf(after.birthDate), + deathYear: yearOf(after.deathDate), + isAlive: !after.deathDate && !after.isDeceased, + photo: null, + frameType: 'round', + ghost, + }) + }) + + changes.forEach((change, index) => { + if (change?.entityType !== 'union' || change.action !== 'create') return + const after = afterOf(change) + if (!after?.partner1PersonId || !after?.partner2PersonId) return + unions.push({ + id: `ghost:${key}:u${change.id || index}`, + partner1Id: resolve(after.partner1PersonId), + partner2Id: resolve(after.partner2PersonId), + unionType: after.unionType || 'mariage', + displayOrder: 9999, + ghost, + }) + }) + + const allUnions = [...(graph.unions || []), ...unions] + changes.forEach((change, index) => { + const after = afterOf(change) + if (!after) return + if (change.entityType === 'parent_child_link' && change.action === 'create') { + if (!after.parentPersonId || !after.childPersonId) return + const parentId = resolve(after.parentPersonId) + const unionId = after.viaUnionId ? String(after.viaUnionId) : unionIdFor(parentId, allUnions) + filiations.push({ + id: `ghost:${key}:f${change.id || index}`, + childId: resolve(after.childPersonId), + parentId: unionId ? null : parentId, + unionId, + parentageType: after.parentageType || 'biologique', + displayOrder: 9999, + ghost, + }) + } else if (change.entityType === 'person' && (change.action === 'update' || change.action === 'delete') && change.entityId) { + const previous = patches.get(String(change.entityId)) + patches.set(String(change.entityId), { + ...previous, + fields: { ...(previous?.fields || {}), ...(change.action === 'update' ? after : {}) }, + deleted: change.action === 'delete' || Boolean(previous?.deleted), + ghost, + }) + } else if (change.entityType === 'annotation' && change.action === 'create' && meta.source !== 'draft') { + // Le brouillon dessine déjà ses annotations : seules celles envoyées ou en attente deviennent fantômes + annotations.push({ + id: `ghost:${key}:a${change.id || index}`, + type: after.type, + x: Number(after.x) || 0, + y: Number(after.y) || 0, + content: after.content || '', + style: after.style || {}, + zIndex: after.zIndex || 0, + ghost, + }) + } + }) + + return { persons, unions, filiations, annotations, patches } +} + +export function emptyGhosts() { + return { persons: [], unions: [], filiations: [], annotations: [], patches: new Map() } +} + +export function mergeGhosts(list = []) { + const merged = emptyGhosts() + for (const g of list) { + if (!g) continue + merged.persons.push(...g.persons) + merged.unions.push(...g.unions) + merged.filiations.push(...g.filiations) + merged.annotations.push(...g.annotations) + for (const [id, patch] of g.patches) if (!merged.patches.has(id)) merged.patches.set(id, patch) + } + return merged +} + +export function hasGhosts(g) { + return Boolean(g && (g.persons.length || g.unions.length || g.filiations.length || g.annotations.length || g.patches.size)) +} + +/** + * Le graphe à placer, fantômes compris. Dans un onglet de famille, seuls les fantômes reliés (de proche en + * proche) à une personne de l'onglet y entrent ; l'arbre entier les prend tous. + * @param {{persons, unions, filiations}} base + * @param {ReturnType} ghosts + * @param {boolean} whole - base = l'arbre entier + */ +export function withGhosts(base, ghosts, whole = false) { + if (!hasGhosts(ghosts)) return base + const keep = new Set(base.persons.map((p) => String(p.id))) + const ghostById = new Map(ghosts.persons.map((p) => [String(p.id), p])) + const allUnions = [...base.unions, ...ghosts.unions] + const unionById = new Map(allUnions.map((u) => [String(u.id), u])) + const parentsOf = (f) => (f.unionId ? [unionById.get(String(f.unionId))?.partner1Id, unionById.get(String(f.unionId))?.partner2Id] : [f.parentId]).filter((id) => id != null).map(String) + + if (whole) { + for (const id of ghostById.keys()) keep.add(id) + } else { + let grew = true + while (grew) { + grew = false + const touch = (a, b) => { + if (keep.has(a) && ghostById.has(b) && !keep.has(b)) { keep.add(b); grew = true } + if (keep.has(b) && ghostById.has(a) && !keep.has(a)) { keep.add(a); grew = true } + } + for (const u of ghosts.unions) touch(String(u.partner1Id), String(u.partner2Id)) + for (const f of ghosts.filiations) for (const p of parentsOf(f)) touch(p, String(f.childId)) + } + } + + const persons = [...base.persons, ...[...ghostById.values()].filter((p) => keep.has(String(p.id)))] + const unions = [...base.unions, ...ghosts.unions.filter((u) => keep.has(String(u.partner1Id)) && keep.has(String(u.partner2Id)))] + const unionIds = new Set(unions.map((u) => String(u.id))) + const filiations = [ + ...base.filiations, + ...ghosts.filiations.filter((f) => keep.has(String(f.childId)) && (f.unionId ? unionIds.has(String(f.unionId)) : keep.has(String(f.parentId)))), + ] + return { persons, unions, filiations } +} + +export function isGhostId(id) { + return typeof id === 'string' && (id.startsWith('ghost:') || id.startsWith('p-ghost:')) +} diff --git a/frontend/src/utils/ghosts.test.js b/frontend/src/utils/ghosts.test.js new file mode 100644 index 0000000..bc3f5e9 --- /dev/null +++ b/frontend/src/utils/ghosts.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { ghostsFromChanges, hasGhosts, isGhostId, mergeGhosts, withGhosts } from './ghosts' + +const base = { + persons: [{ id: 'jean' }, { id: 'marie' }, { id: 'paul' }], + unions: [{ id: 'u1', partner1Id: 'jean', partner2Id: 'marie' }], + filiations: [{ id: 'f1', childId: 'paul', unionId: 'u1', parentId: null }], +} + +describe('fantômes des propositions', () => { + it("un enfant proposé à Jean se rattache au couple Jean et Marie, pas à un parent inconnu", () => { + const g = ghostsFromChanges([ + { id: 'c1', entityType: 'person', action: 'create', afterJson: { ref: 'tmp:a', firstName: 'Lucie', lastName: 'Martin', birthDate: '1990-03-01' } }, + { id: 'c2', entityType: 'parent_child_link', action: 'create', afterJson: { parentPersonId: 'jean', childPersonId: 'tmp:a' } }, + ], { source: 'pending', sessionId: 's1', label: 'Cousine' }, base) + expect(g.persons).toHaveLength(1) + expect(g.persons[0]).toMatchObject({ firstName: 'Lucie', birthYear: 1990, ghost: { source: 'pending', sessionId: 's1' } }) + expect(g.filiations[0]).toMatchObject({ childId: g.persons[0].id, unionId: 'u1', parentId: null }) + expect(isGhostId(g.persons[0].id)).toBe(true) + }) + + it('les changements sur une personne existante deviennent une marque, pas un double', () => { + const g = ghostsFromChanges([ + { entityType: 'person', action: 'update', entityId: 'paul', after: { birthPlace: 'Lille' } }, + { entityType: 'person', action: 'update', entityId: 'paul', after: { profession: 'Facteur' } }, + ], { source: 'sent', key: 'k' }, base) + expect(g.persons).toHaveLength(0) + expect(g.patches.get('paul').fields).toEqual({ birthPlace: 'Lille', profession: 'Facteur' }) + }) + + it("le brouillon ne double pas ses annotations, déjà dessinées ; une proposition envoyée oui", () => { + const change = { entityType: 'annotation', action: 'create', after: { type: 'text', x: 1, y: 2, content: 'Mamie' } } + expect(ghostsFromChanges([change], { source: 'draft' }).annotations).toHaveLength(0) + expect(ghostsFromChanges([change], { source: 'sent', key: 's' }).annotations).toHaveLength(1) + }) + + it("dans un onglet, seuls les fantômes reliés de proche en proche à une personne de l'onglet entrent", () => { + const g = ghostsFromChanges([ + { entityType: 'person', action: 'create', after: { ref: 'tmp:a', firstName: 'Enfant' } }, + { entityType: 'person', action: 'create', after: { ref: 'tmp:b', firstName: 'Petit-enfant' } }, + { entityType: 'person', action: 'create', after: { ref: 'tmp:c', firstName: 'Ailleurs' } }, + { entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'paul', childPersonId: 'tmp:a' } }, + { entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'tmp:a', childPersonId: 'tmp:b' } }, + { entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'inconnu-hors-onglet', childPersonId: 'tmp:c' } }, + ], { source: 'pending', sessionId: 's2' }, base) + const tab = withGhosts(base, g) + expect(tab.persons.map((p) => p.firstName).filter(Boolean)).toEqual(['Enfant', 'Petit-enfant']) + expect(tab.filiations).toHaveLength(3) + expect(withGhosts(base, g, true).persons).toHaveLength(6) + }) + + it("deux propositions qui utilisent la même référence provisoire ne se mélangent pas", () => { + const one = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'A' } }], { source: 'pending', sessionId: 's1' }) + const two = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'B' } }], { source: 'pending', sessionId: 's2' }) + const merged = mergeGhosts([one, two]) + expect(new Set(merged.persons.map((p) => p.id)).size).toBe(2) + expect(hasGhosts(merged)).toBe(true) + expect(withGhosts(base, mergeGhosts([]))).toBe(base) + }) +}) diff --git a/frontend/src/utils/proposalSummary.js b/frontend/src/utils/proposalSummary.js new file mode 100644 index 0000000..0d26af4 --- /dev/null +++ b/frontend/src/utils/proposalSummary.js @@ -0,0 +1,24 @@ +import { t } from '../i18n/index.js' + +function plural(count, one, many) { + return count > 1 ? t(many, { n: count }) : t(one, { n: count }) +} + +// Ce que contient une proposition, en une ligne : « 1 personne, 2 liens, 1 photo sur l'arbre » +export function summarizeChanges(changes = []) { + const count = { person: 0, update: 0, link: 0, annotation: 0, other: 0 } + for (const change of changes) { + if (change.entityType === 'person' && change.action === 'create') count.person += 1 + else if (change.entityType === 'person') count.update += 1 + else if (change.entityType === 'union' || change.entityType === 'parent_child_link') count.link += 1 + else if (change.entityType === 'annotation') count.annotation += 1 + else count.other += 1 + } + return [ + count.person && plural(count.person, '{n} personne', '{n} personnes'), + count.update && plural(count.update, '{n} fiche modifiée', '{n} fiches modifiées'), + count.link && plural(count.link, '{n} lien', '{n} liens'), + count.annotation && plural(count.annotation, "{n} ajout sur l'arbre", "{n} ajouts sur l'arbre"), + count.other && plural(count.other, '{n} autre changement', '{n} autres changements'), + ].filter(Boolean).join(', ') +} diff --git a/frontend/src/utils/sentProposals.js b/frontend/src/utils/sentProposals.js new file mode 100644 index 0000000..9c14cb1 --- /dev/null +++ b/frontend/src/utils/sentProposals.js @@ -0,0 +1,53 @@ +// Propositions envoyées par la famille depuis ce navigateur, gardées jusqu'à leur relecture pour les dessiner +// en fantôme « En attente ». Ce sont ses propres changements : rien d'autre n'est gardé. Le stockage peut manquer +// (navigation privée, quota) : on dessine alors simplement sans fantômes. + +const MAX_KEPT = 30 +// Au-delà, une proposition jamais relue n'est plus dessinée (le relecteur l'a peut-être oubliée, pas la famille) +const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000 + +const keyOf = (treeId) => `parenthese-propositions-${treeId}` + +export function readSentProposals(treeId, now = Date.now()) { + if (!treeId) return [] + try { + const parsed = JSON.parse(localStorage.getItem(keyOf(treeId)) || '[]') + if (!Array.isArray(parsed)) return [] + return parsed.filter((p) => p && typeof p.sessionId === 'string' && Array.isArray(p.changes) + && now - (Date.parse(p.createdAt) || now) < MAX_AGE_MS) + } catch { + return [] + } +} + +function write(treeId, list) { + try { + if (list.length) localStorage.setItem(keyOf(treeId), JSON.stringify(list.slice(-MAX_KEPT))) + else localStorage.removeItem(keyOf(treeId)) + } catch { + // Stockage indisponible : pas de fantômes « En attente », rien d'autre ne change + } +} + +// Une photo posée sur l'arbre garde dans son contenu l'adresse affichée, avec le jeton médias : seul le chemin +// est gardé, l'adresse est refaite au dessin +function withoutPhotoUrl(change) { + if (change?.entityType !== 'annotation' || typeof change.after?.content !== 'string' || !change.after.content.startsWith('{')) return change + try { + const { url: _url, ...rest } = JSON.parse(change.after.content) + return { ...change, after: { ...change.after, content: JSON.stringify(rest) } } + } catch { + return change + } +} + +export function rememberSentProposal(treeId, proposal) { + if (!treeId || !proposal?.sessionId) return + const kept = { ...proposal, changes: (proposal.changes || []).map(withoutPhotoUrl) } + write(treeId, [...readSentProposals(treeId).filter((p) => p.sessionId !== proposal.sessionId), kept]) +} + +export function forgetSentProposal(treeId, sessionId) { + if (!treeId) return + write(treeId, readSentProposals(treeId).filter((p) => p.sessionId !== sessionId)) +}