Merge pull request #151 from IliesAllali/feat/propositions-fantomes

1.5.1 : les propositions de la famille sur l'arbre, photos de la famille, ajout d'un proche depuis la fiche
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-25 22:41:17 +01:00
commit 7ed0117890
31 files changed
+1736 -117

No files matched your search

+33 -1
View File
@@ -2,10 +2,42 @@
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
## Unreleased
## 1.5.1 (unreleased)
### Suggestions show on the tree
- A suggestion waiting for review now shows on the tree, where it will go: a suggested person is drawn faded, with a dotted circle and a "Suggested" tag, and their links are dotted. Words, drawings and photos placed on the tree show see-through. A person with a suggested change gets a small dot.
- Whoever manages the tree opens a suggested person to see who suggested it and what else the suggestion holds, and accepts or rejects it right there. A person with a suggested change shows the fields in their profile, with the same buttons. "See details" opens the review panel to sort change by change.
- The family sees what they are adding as a "Draft" before sending it, then as "Waiting" until it is reviewed. This is kept in their browser only, and cleared once the suggestion is reviewed.
- After adding a person, the send window no longer opens by itself. The family can add several people, then send once with Send.
### Photos placed on the tree by the family
- A family member can now place a photo on the tree. Before, the upload was refused without a word and the photo never showed. The photo waits for review: it is kept if accepted, deleted if rejected, and an upload never sent is deleted after 7 days. At most 50 photos wait on a tree at once.
- Accepted words, drawings and photos are now placed by the server when the suggestion is accepted. Before, the reviewer's browser placed them afterwards and could lose them, and none appeared when the family's changes apply directly.
### Adding relatives from a profile
- "Someone elsewhere": relatives can add a person found in public records too. It goes into their changes and the tree manager reviews it, like any other proposal. Places, the "needs checking" mark and the source link now travel with a proposed new person.
- In a profile's Links tab, searching for a parent, child or partner who is not in the tree yet now offers "Create …", which opens the add panel already filled in.
- Profiles have a Siblings section, with "Add a sibling".
- The Info tab has quick buttons to add a parent, partner, child or sibling without going into edit mode first.
- The add panel opened from a profile comes with the link already set, and suggests the family name for a child or a sibling.
- Adding someone whose first and last name match a person already in the tree shows a warning, with a button to open that person's profile.
### Fixes
- The "needs checking" mark sits on the corner of the portrait, half outside, so it no longer covers the face.
- "Update the tree" no longer clears your changes when part of them fails to save: what failed stays in your edits, with a message.
- A suggestion already reviewed elsewhere (another tab, a co-manager) now says so, instead of a message about emails, and the tree updates.
- After the family sends their changes, the tree reloads: a photo just sent no longer looks accepted.
- Zooming with the mouse wheel no longer logs an error in the browser console.
### For self-hosters
- New table `annotation_photo_uploads` (migration `20261025_003`, additive).
- New route `GET /trees/:id/contributions/sessions/:sessionId/status`, which returns only the status of a suggestion to someone who can read the tree.
- Anonymous counters for editing (person added and from where, suggestion sent or reviewed, edits saved), with no content from the tree. As before, nothing is sent without `VITE_POSTHOG_KEY`.
## 1.5.0 (2026-10-25)
@@ -0,0 +1,16 @@
-- Photos posées sur l'arbre par la famille, en attente de relecture (1.5.1). Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "annotation_photo_uploads" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "annotation_photo_uploads_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "annotation_photo_uploads_treeId_createdAt_idx" ON "annotation_photo_uploads"("treeId", "createdAt");
-- AddForeignKey
ALTER TABLE "annotation_photo_uploads" ADD CONSTRAINT "annotation_photo_uploads_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+44 -29
View File
@@ -121,36 +121,37 @@ model UserIdentity {
}
model Tree {
id String @id @default(cuid())
ownerUserId String
slug String @unique
name String
description String?
id String @id @default(cuid())
ownerUserId String
slug String @unique
name String
description String?
// Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre
icon String?
rootPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
accessPasswords TreeAccessPasswords?
sharePassword TreeSharePassword?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
immichConfig TreeImmichConfig?
persons Person[]
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
mediaPersonTags MediaPersonTag[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
lifeEvents LifeEvent[]
invitations TreeInvitation[]
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
icon String?
rootPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
accessPasswords TreeAccessPasswords?
sharePassword TreeSharePassword?
memberships TreeMembership[]
sharedUserAccesses UserTreeAccess[]
settings TreeSettings?
immichConfig TreeImmichConfig?
persons Person[]
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
mediaPersonTags MediaPersonTag[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
lifeEvents LifeEvent[]
invitations TreeInvitation[]
annotationPhotoUploads AnnotationPhotoUpload[]
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
@@index([ownerUserId])
@@map("trees")
@@ -214,6 +215,20 @@ model TreeInvitation {
@@map("tree_invitations")
}
// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est
// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est
// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne.
model AnnotationPhotoUpload {
id String @id
treeId String
filePath String
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, createdAt])
@@map("annotation_photo_uploads")
}
model UserTreeAccess {
id String @id @default(cuid())
treeId String
+234
View File
@@ -0,0 +1,234 @@
import path from 'node:path'
import { Prisma, type PrismaClient } from '@prisma/client'
import { z } from 'zod'
import { InvalidAnnotationContentError, sanitizeAnnotationContentForWrite } from './annotation-content.js'
// Annotations proposées par la famille (dessin, texte, sticker, photo posée sur l'arbre). Avant la 1.5.1, le
// serveur les laissait au navigateur du relecteur, qui les posait après coup : une proposition acceptée pouvait
// ne jamais apparaître (onglet fermé, erreur réseau), et rien n'apparaissait en mode « direct ». Elles sont
// maintenant posées dans la transaction qui accepte la proposition.
type Tx = Prisma.TransactionClient
type Db = PrismaClient | Tx
export const MAX_ANNOTATIONS_PER_TREE = 5000
// Un envoi de photo jamais proposé vit le temps d'un brouillon de la famille
export const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000
// Une famille pose quelques photos à la fois ; au-delà, c'est un envoi en boucle
export const MAX_PENDING_PHOTOS_PER_TREE = 50
const MAX_STYLE_CHARS = 4000
const styleSchema = z.record(z.string(), z.unknown()).refine(
(value) => JSON.stringify(value).length <= MAX_STYLE_CHARS,
{ message: 'style_too_large' },
)
const annotationCreateSchema = z.object({
type: z.enum(['drawing', 'sticker', 'text', 'photo']),
x: z.number().finite(),
y: z.number().finite(),
content: z.string().min(1).max(50000),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
const annotationUpdateSchema = z.object({
x: z.number().finite().optional(),
y: z.number().finite().optional(),
content: z.string().min(1).max(50000).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
export class AnnotationChangeError extends Error {
constructor(public readonly code: string) {
super(code)
}
}
type AnnotationChange = { action: string; entityId?: string | null; after?: unknown }
// Contenu vérifié comme à l'écriture directe : une photo doit être une photo de cet arbre
function cleanContent(content: string, treeId: string): string {
try {
return sanitizeAnnotationContentForWrite(content, treeId)
} catch (error) {
if (error instanceof InvalidAnnotationContentError) throw new AnnotationChangeError('invalid_annotation_photo_path')
throw error
}
}
// Vérifie une proposition d'annotation dès l'envoi, pour qu'une relecture n'échoue pas sur un contenu refusé
export function validateAnnotationChange(change: AnnotationChange, treeId: string): void {
if (change.action === 'create') {
const parsed = annotationCreateSchema.safeParse(change.after)
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
cleanContent(parsed.data.content, treeId)
return
}
if (change.action === 'update') {
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
const parsed = annotationUpdateSchema.safeParse(change.after)
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
if (parsed.data.content !== undefined) cleanContent(parsed.data.content, treeId)
return
}
if (change.action === 'delete') {
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
return
}
throw new AnnotationChangeError('unsupported_change_action_for_annotation')
}
// L'« après » d'une proposition d'annotation tel qu'il est gardé : contenu nettoyé comme à l'écriture directe
// (photoPath canonique, sans adresse), pour qu'on le relise toujours de la même façon
export function normalizedAnnotationAfter(change: AnnotationChange, treeId: string): unknown {
const after = change.after
if (!after || typeof after !== 'object' || Array.isArray(after)) return after
const content = (after as { content?: unknown }).content
if (typeof content !== 'string') return after
return { ...(after as Record<string, unknown>), content: cleanContent(content, treeId) }
}
// Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null
export function annotationPhotoId(after: unknown, treeId: string): string | null {
if (!after || typeof after !== 'object' || Array.isArray(after)) return null
const content = (after as { content?: unknown }).content
if (typeof content !== 'string' || !content.startsWith('{')) return null
try {
const parsed = JSON.parse(content) as { photoPath?: unknown }
const prefix = `/trees/${treeId}/annotation-photos/`
if (typeof parsed.photoPath !== 'string' || !parsed.photoPath.startsWith(prefix)) return null
const photoId = parsed.photoPath.slice(prefix.length)
return /^[A-Za-z0-9-]+$/.test(photoId) ? photoId : null
} catch {
return null
}
}
// Pose une annotation acceptée. La photo d'une famille devient définitive : sa ligne d'attente disparaît.
export async function applyAnnotationChange(tx: Tx, treeId: string, change: AnnotationChange, actorUserId: string | null): Promise<{ entityId: string | null }> {
validateAnnotationChange(change, treeId)
if (change.action === 'create') {
const data = annotationCreateSchema.parse(change.after)
const count = await tx.annotation.count({ where: { treeId, deletedAt: null } })
if (count + 1 > MAX_ANNOTATIONS_PER_TREE) throw new AnnotationChangeError('annotation_limit_reached')
const created = await tx.annotation.create({
data: {
treeId,
type: data.type,
x: data.x,
y: data.y,
content: cleanContent(data.content, treeId),
style: (data.style ?? {}) as Prisma.InputJsonValue,
zIndex: data.zIndex ?? 0,
createdBy: actorUserId,
},
select: { id: true },
})
const photoId = annotationPhotoId(change.after, treeId)
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
return { entityId: created.id }
}
if (change.action === 'update') {
const data = annotationUpdateSchema.parse(change.after)
const patch: Prisma.AnnotationUpdateManyMutationInput = {}
if (data.x !== undefined) patch.x = data.x
if (data.y !== undefined) patch.y = data.y
if (data.content !== undefined) patch.content = cleanContent(data.content, treeId)
if (data.style !== undefined) patch.style = data.style as Prisma.InputJsonValue
if (data.zIndex !== undefined) patch.zIndex = data.zIndex
if (Object.keys(patch).length > 0) {
await tx.annotation.updateMany({ where: { id: change.entityId as string, treeId, deletedAt: null }, data: patch })
}
const photoId = annotationPhotoId(change.after, treeId)
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
return { entityId: change.entityId ?? null }
}
await tx.annotation.updateMany({
where: { id: change.entityId as string, treeId, deletedAt: null },
data: { deletedAt: new Date() },
})
return { entityId: change.entityId ?? null }
}
// Photos citées par les propositions d'annotation encore en attente sur cet arbre (hors une session donnée).
// Lu dans le JSON décodé, pas dans son texte : un chemin échappé (\/trees\/…) reste reconnu. Un arbre a au plus
// quelques centaines de propositions en attente.
async function photosInPendingProposals(db: Db, treeId: string, excludeSessionId?: string): Promise<Set<string>> {
const changes = await db.contributionChange.findMany({
where: {
entityType: 'annotation',
session: { treeId, status: 'pending', ...(excludeSessionId ? { id: { not: excludeSessionId } } : {}) },
},
select: { afterJson: true },
})
const cited = new Set<string>()
for (const change of changes) {
const photoId = annotationPhotoId(change.afterJson, treeId)
if (photoId) cited.add(photoId)
}
return cited
}
// Photos d'une famille refusées pendant une relecture : effacées si leur ligne d'attente existe encore (une
// acceptation la retire) et si aucune autre proposition en attente ne les cite. Appelé une fois, après toutes les
// décisions. Renvoie les chemins à effacer une fois la transaction validée.
export async function releaseRejectedPhotos(tx: Tx, treeId: string, rejectedAfters: unknown[], sessionId: string): Promise<string[]> {
const candidates = new Set(rejectedAfters.map((after) => annotationPhotoId(after, treeId)).filter((id): id is string => Boolean(id)))
if (candidates.size === 0) return []
const cited = await photosInPendingProposals(tx, treeId, sessionId)
const removed: string[] = []
for (const photoId of candidates) {
if (cited.has(photoId)) continue
const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } })
if (!upload) continue
await tx.annotationPhotoUpload.delete({ where: { id: photoId } })
removed.push(upload.filePath)
}
return removed
}
// Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur
// cet arbre seulement. Renvoie les chemins à effacer.
export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new Date()): Promise<string[]> {
const stale = await db.annotationPhotoUpload.findMany({
where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } },
select: { id: true, filePath: true },
})
if (stale.length === 0) return []
const cited = await photosInPendingProposals(db, treeId)
const removed: string[] = []
for (const upload of stale) {
if (cited.has(upload.id)) continue
await db.annotationPhotoUpload.delete({ where: { id: upload.id } })
removed.push(upload.filePath)
}
return removed
}
// Réserve une place d'envoi en attente sous un verrou par arbre : deux envois simultanés ne dépassent pas le
// plafond. Renvoie false si l'arbre est plein. La ligne est créée avant l'écriture du fichier ; si l'écriture
// échoue, l'appelant la retire (forgetPendingPhoto).
export async function reservePendingPhoto(prisma: PrismaClient, treeId: string, photoId: string, filePath: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'annotation-photos:' + treeId}))`
if (await tx.annotationPhotoUpload.count({ where: { treeId } }) >= MAX_PENDING_PHOTOS_PER_TREE) return false
await tx.annotationPhotoUpload.create({ data: { id: photoId, treeId, filePath } })
return true
})
}
export async function forgetPendingPhoto(db: Db, photoId: string): Promise<void> {
await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } })
}
// Chemin relatif sûr d'une photo d'annotation (sous <treeId>/annotation-photos/)
export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string {
return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`)
}
+51 -6
View File
@@ -5,8 +5,10 @@ import { Prisma } from '@prisma/client'
import { z } from 'zod'
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { AnnotationChangeError, applyAnnotationChange, normalizedAnnotationAfter, releaseRejectedPhotos, validateAnnotationChange } from '../lib/annotation-changes.js'
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
import { removeStoredFile } from '../lib/media-files.js'
import { canReadTree } from '../lib/tree-access.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js'
@@ -32,6 +34,8 @@ const sessionParamsSchema = z.object({
// remplissait la base à 12 Mo par envoi
const MAX_CHANGE_JSON_CHARS = 64_000
const MAX_PENDING_SESSIONS_PER_TREE = 200
// Annotations proposées avant la 1.5.1 : écartées à la relecture si elles ne se posent pas
const SKIPPABLE_ANNOTATION_ERRORS = new Set(['invalid_annotation_change', 'invalid_annotation_photo_path', 'unsupported_change_action_for_annotation'])
const boundedJson = z.unknown().optional().refine(
(value) => value === undefined || JSON.stringify(value).length <= MAX_CHANGE_JSON_CHARS,
{ message: 'change_too_large' },
@@ -325,10 +329,15 @@ async function applyContributionChange(
// Fichiers à effacer une fois la transaction validée (suppression d'une personne)
filesToRemove: string[] = [],
): Promise<{ entityId: string | null }> {
// Les annotations sont appliquées par le client après la relecture (batchAnnotations) :
// rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture.
// Annotation (dessin, texte, sticker, photo posée sur l'arbre) : posée ici, dans la transaction qui accepte
// la proposition. Avant la 1.5.1, le navigateur du relecteur la posait après coup, et pouvait la perdre.
if (change.entityType === 'annotation') {
return { entityId: change.entityId ?? null }
try {
return await applyAnnotationChange(tx, treeId, { action: change.action, entityId: change.entityId, after: change.after }, actorUserId)
} catch (error) {
if (error instanceof AnnotationChangeError) throw new ContributionRouteError(400, error.code)
throw error
}
}
// Moment proposé par la famille : il existe déjà, on le rend visible
@@ -1214,6 +1223,17 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'media_changes_not_allowed' })
}
// Une annotation refusée à l'écriture le serait aussi à la relecture : on le dit tout de suite
for (const change of payload.data.changes) {
if (change.entityType !== 'annotation') continue
try {
validateAnnotationChange(change, params.data.id)
} catch (error) {
if (error instanceof AnnotationChangeError) return reply.code(400).send({ error: error.code })
throw error
}
}
try {
const context = await resolveSubmissionContext(app, request.actor, params.data.id)
const now = new Date()
@@ -1275,7 +1295,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
entityId: effectiveEntityId,
action: change.action,
beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before),
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after),
// Annotation : gardée nettoyée (photoPath canonique), pour être relue sans ambiguïté à la relecture
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(normalizedAnnotationAfter(change, params.data.id)) : change.after),
conflictState: change.conflictState ?? 'none',
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
@@ -1374,6 +1395,21 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
}
})
// État d'une proposition, pour la personne qui l'a envoyée : ses fantômes « en attente » s'effacent quand elle
// est relue. Seulement le statut, à qui lit l'arbre et connaît l'identifiant (rendu à l'envoi).
app.get('/trees/:id/contributions/sessions/:sessionId/status', { config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = sessionParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
const session = await app.prisma.contributionSession.findFirst({
where: { id: params.data.sessionId, treeId: params.data.id },
select: { status: true },
})
if (!session) return reply.code(404).send({ error: 'contribution_session_not_found' })
return reply.send({ status: session.status })
})
app.post('/trees/:id/contributions/sessions/:sessionId/review', async (request, reply) => {
const params = sessionParamsSchema.safeParse(request.params)
if (!params.success) {
@@ -1429,6 +1465,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
let rejectedCount = 0
let conflictCount = 0
const refs: RefMap = new Map()
// Photos posées sur l'arbre par la famille : celles des annotations refusées sont libérées après toutes les
// décisions. Une annotation acceptée dans cette même relecture a déjà retiré la ligne d'attente de sa photo,
// qui n'est donc plus effacée (avant, la libération dans la boucle effaçait une photo acceptée plus loin).
const rejectedAnnotationAfters: unknown[] = []
for (const change of byApplicationOrder(session.changes)) {
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
@@ -1467,10 +1507,12 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
)
approvedCount += 1
} catch (error) {
// Lien vers une personne ajoutée mais refusée : le lien est écarté, pas la relecture
if (error instanceof ContributionRouteError && error.code === 'unresolved_reference') {
// Lien vers une personne ajoutée mais refusée, ou annotation d'avant la 1.5.1 que le serveur ne
// peut pas poser (contenu incomplet) : la proposition est écartée, pas la relecture
if (error instanceof ContributionRouteError && (error.code === 'unresolved_reference' || SKIPPABLE_ANNOTATION_ERRORS.has(error.code))) {
decision = 'rejected'
rejectedCount += 1
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
} else {
throw error
}
@@ -1482,6 +1524,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
data: { status: 'rejected', deletedAt: new Date() },
})
}
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
if (change.entityType === 'media' && change.entityId) {
await tx.mediaItem.updateMany({
where: { id: change.entityId, treeId: params.data.id },
@@ -1502,6 +1545,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
})
}
filesToRemove.push(...await releaseRejectedPhotos(tx, params.data.id, rejectedAnnotationAfters, session.id))
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
const updatedSession = await tx.contributionSession.update({
+26 -6
View File
@@ -25,6 +25,7 @@ import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
import { forgetPendingPhoto, reservePendingPhoto, sweepStalePendingPhotos } from '../lib/annotation-changes.js'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_FILE_BYTES = 20 * 1024 * 1024
@@ -1123,8 +1124,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
photoId: z.string().min(1),
})
// Photo posée sur l'arbre. Un gestionnaire la pose tout de suite ; la famille (lien de partage ou membre) la
// propose : le fichier attend la relecture, noté dans annotation_photo_uploads (lib/annotation-changes.ts).
app.post('/trees/:id/annotation-photos', UPLOAD_RATE_LIMIT, async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
if (!request.actor) {
return reply.code(401).send({ error: 'authentication_required' })
}
@@ -1138,9 +1141,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
const isManager = request.actor.kind === 'user' && Boolean(await requireAdminMembership(app, request.actor.userId, params.data.id))
if (!isManager) {
try {
await resolveSubmissionContext(app, request.actor, params.data.id)
} catch (error) {
if (error instanceof ContributionRouteError) return reply.code(error.statusCode).send({ error: error.code })
throw error
}
const stale = await sweepStalePendingPhotos(app.prisma, params.data.id)
await Promise.all(stale.map((filePath) => removeStoredFile(getStorageRoot(), filePath)))
}
const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64')
@@ -1180,8 +1190,18 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_photo_path' })
}
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
// Famille : la place est réservée sous un verrou par arbre avant d'écrire, pour tenir le plafond même quand
// plusieurs envois arrivent ensemble
if (!isManager && !(await reservePendingPhoto(app.prisma, params.data.id, photoId, relativePath))) {
return reply.code(429).send({ error: 'too_many_pending_photos' })
}
try {
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
} catch (error) {
if (!isManager) await forgetPendingPhoto(app.prisma, photoId)
throw error
}
return reply.code(201).send({
photoId,
@@ -0,0 +1,217 @@
import { existsSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Propositions de la famille sur l'arbre (1.5.1) : photos posées sur l'arbre, annotations posées par le serveur
// à l'acceptation, état d'une proposition. Sur une vraie base, comme toute règle de droits :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/propositions-fantomes.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let prisma: typeof import('../src/lib/prisma.js')['prisma']
let ip = 0
const nextIp = () => `10.2.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const mail = (name: string) => `${name}-${run}@exemple.fr`
// PNG d'un pixel
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const storage = mkdtempSync(path.join(tmpdir(), 'pth-151-'))
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
return { status: res.statusCode, body: res.body ? res.json() : null }
}
async function account(name: string) {
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name })
expect(res.status).toBe(201)
return { token: res.body.token as string, id: res.body.user.id as string }
}
describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base', () => {
let owner: { token: string; id: string }
let treeId: string
let family: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
;({ prisma } = await import('../src/lib/prisma.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
owner = await account('proprio')
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `fantomes-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
expect(tree.status).toBe(201)
treeId = tree.body.tree?.id ?? tree.body.id
const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' })
expect(unlocked.status).toBe(200)
family = unlocked.body.token
})
afterAll(async () => {
await app?.close()
})
const upload = (token?: string) => call('POST', `/trees/${treeId}/annotation-photos`, token, {
fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})
const photoAnnotation = (photoPath: string) => ({ type: 'photo', x: 10, y: 20, content: JSON.stringify({ photoPath, width: 1, height: 1 }), style: {}, zIndex: 1 })
const propose = (token: string, changes: unknown[]) => call('POST', `/trees/${treeId}/contributions/sessions`, token, { title: 'Proposition de la famille', submittedByLabel: 'Cousine', changes })
const review = (sessionId: string, decision: 'approved' | 'rejected') => call('POST', `/trees/${treeId}/contributions/sessions/${sessionId}/review`, owner.token, { decision })
const annotations = async () => (await call('GET', `/trees/${treeId}/annotations`, owner.token)).body.annotations as Array<{ id: string; type: string; content: string }>
const fileOf = async (photoId: string) => (await prisma.annotationPhotoUpload.findUnique({ where: { id: photoId } }))?.filePath
it("personne d'extérieur ne pose une photo : sans jeton 401, un compte sans lien avec l'arbre 403", async () => {
expect((await upload()).status).toBe(401)
const stranger = await account('inconnu')
expect((await upload(stranger.token)).status).toBe(403)
})
it('la photo de la famille attend la relecture, puis devient une annotation au moment où elle est acceptée', async () => {
const sent = await upload(family)
expect(sent.status).toBe(201)
const filePath = await fileOf(sent.body.photoId)
expect(filePath).toBeTruthy()
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
expect(proposed.status).toBe(201)
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false)
expect((await review(proposed.body.session.id, 'approved')).status).toBe(200)
expect((await annotations()).some((a) => a.type === 'photo' && a.content.includes(sent.body.photoId))).toBe(true)
expect(await fileOf(sent.body.photoId)).toBeUndefined()
expect(existsSync(path.join(storage, filePath as string))).toBe(true)
})
it('refusée, la photo de la famille est effacée du disque', async () => {
const sent = await upload(family)
const filePath = await fileOf(sent.body.photoId) as string
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
expect((await review(proposed.body.session.id, 'rejected')).status).toBe(200)
expect(await fileOf(sent.body.photoId)).toBeUndefined()
expect(existsSync(path.join(storage, filePath))).toBe(false)
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false)
})
it('un dessin accepté est posé par le serveur, sans geste du navigateur', async () => {
const before = (await annotations()).length
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'drawing', x: 1, y: 2, content: 'M0 0 L10 10', style: { color: '#b4532a' } } }])
expect((await review(proposed.body.session.id, 'approved')).status).toBe(200)
expect((await annotations()).length).toBe(before + 1)
})
it("une ancienne proposition d'annotation incomplète est écartée sans bloquer le reste de la relecture", async () => {
const before = (await annotations()).length
const session = await prisma.contributionSession.create({
data: {
treeId, submittedByLabel: 'Cousine', mode: 'tree_access', status: 'pending', title: 'Avant la 1.5.1',
changes: { create: [
{ entityType: 'annotation', action: 'create', afterJson: { type: 'drawing', x: 0, y: 0 } },
{ entityType: 'annotation', action: 'create', afterJson: { type: 'text', x: 5, y: 5, content: 'Mamie' } },
] },
},
})
const reviewed = await review(session.id, 'approved')
expect(reviewed.status).toBe(200)
expect(reviewed.body.approvedCount).toBe(1)
expect(reviewed.body.rejectedCount).toBe(1)
expect((await annotations()).length).toBe(before + 1)
})
it("une photo qui n'est pas de cet arbre est refusée dès l'envoi", async () => {
const res = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation('/trees/un-autre-arbre/annotation-photos/abc') }])
expect(res.status).toBe(400)
expect(res.body.error).toBe('invalid_annotation_photo_path')
})
it("l'état d'une proposition : lisible par qui lit l'arbre, pas par un compte sans lien", async () => {
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'sticker', x: 0, y: 0, content: '🌳' } }])
const url = `/trees/${treeId}/contributions/sessions/${proposed.body.session.id}/status`
expect((await call('GET', url, family)).body.status).toBe('pending')
const stranger = await account('curieux')
expect((await call('GET', url, stranger.token)).status).toBe(403)
await review(proposed.body.session.id, 'approved')
expect((await call('GET', url, family)).body.status).toBe('approved')
})
it("un envoi jamais proposé depuis 7 jours est effacé au suivant ; celui d'une proposition en attente reste", async () => {
const orphan = await upload(family)
const kept = await upload(family)
await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(kept.body.photoPath) }])
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
await prisma.annotationPhotoUpload.updateMany({ where: { id: { in: [orphan.body.photoId, kept.body.photoId] } }, data: { createdAt: old } })
const orphanPath = await fileOf(orphan.body.photoId) as string
expect((await upload(family)).status).toBe(201)
expect(await fileOf(orphan.body.photoId)).toBeUndefined()
expect(existsSync(path.join(storage, orphanPath))).toBe(false)
expect(await fileOf(kept.body.photoId)).toBeTruthy()
})
it('revue : refusée puis acceptée dans la même relecture, la photo reste sur le disque', async () => {
const sent = await upload(family)
const filePath = await fileOf(sent.body.photoId) as string
const proposed = await propose(family, [
{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) },
{ entityType: 'annotation', action: 'create', after: { ...photoAnnotation(sent.body.photoPath), x: 99 } },
])
const session = await prisma.contributionSession.findUnique({ where: { id: proposed.body.session.id }, include: { changes: { orderBy: { createdAt: 'asc' } } } })
const [first, second] = session!.changes
const res = await call('POST', `/trees/${treeId}/contributions/sessions/${session!.id}/review`, owner.token, {
decision: 'approved',
changeDecisions: [{ changeId: first.id, decision: 'rejected' }, { changeId: second.id, decision: 'approved' }],
})
expect(res.status).toBe(200)
expect(existsSync(path.join(storage, filePath))).toBe(true)
})
it("revue : un chemin de photo échappé dans le JSON compte quand même comme cité par une proposition en attente", async () => {
const sent = await upload(family)
const filePath = await fileOf(sent.body.photoId) as string
const plain = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
const escaped = `{"photoPath":"${sent.body.photoPath.replaceAll('/', '\/')}","width":1,"height":1}`
const other = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'photo', x: 1, y: 1, content: escaped } }])
expect(other.status).toBe(201)
await review(plain.body.session.id, 'rejected')
expect(existsSync(path.join(storage, filePath))).toBe(true)
await review(other.body.session.id, 'approved')
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(true)
expect(existsSync(path.join(storage, filePath))).toBe(true)
})
it('revue : 70 envois simultanés sur un arbre neuf ne dépassent pas le plafond de 50', async () => {
const fresh = await call('POST', '/trees', owner.token, { name: 'Arbre neuf', slug: `neuf-${run}`, visitorPassword: 'partage-neuf-1', contributorPassword: 'partage-neuf-1' })
const freshId = fresh.body.tree?.id ?? fresh.body.id
const access = (await call('POST', `/trees/${freshId}/access/unlock`, undefined, { password: 'partage-neuf-1' })).body.token
const results = await Promise.all(Array.from({ length: 70 }, () => call('POST', `/trees/${freshId}/annotation-photos`, access, {
fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})))
expect(results.filter((r) => r.status === 201).length).toBe(50)
expect(await prisma.annotationPhotoUpload.count({ where: { treeId: freshId } })).toBe(50)
})
it('au-delà de 50 photos en attente sur un arbre, la famille est arrêtée ; un gestionnaire non', async () => {
const existing = await prisma.annotationPhotoUpload.count({ where: { treeId } })
await prisma.annotationPhotoUpload.createMany({
data: Array.from({ length: 50 - existing }, (_, index) => ({ id: `rempli-${run}-${index}`, treeId, filePath: `${treeId}/annotation-photos/rempli-${index}.png` })),
})
expect((await upload(family)).status).toBe(429)
expect((await upload(owner.token)).status).toBe(201)
})
})
+148 -14
View File
@@ -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 === '') {
@@ -693,6 +696,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
@@ -704,6 +708,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
@@ -900,9 +954,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
@@ -967,8 +1030,9 @@ function App() {
}
img.onerror = () => URL.revokeObjectURL(img.src)
img.src = URL.createObjectURL(file)
} catch {
// silent — user can retry
} catch (error) {
// Avant la 1.5.1, l'échec passait sous silence : la photo n'apparaissait pas et rien ne disait pourquoi
setDraftNotice(getAccountErrorMessage(error) || t("La photo n'a pas pu être envoyée. Réessayez."))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
@@ -1045,7 +1109,26 @@ function App() {
const [addPersonLoading, setAddPersonLoading] = useState(false)
const [addPersonError, setAddPersonError] = useState('')
const handleOpenAddPerson = useCallback(() => {
// Ajouter un proche depuis une fiche (1.5.1) : le panneau s'ouvre avec le lien déjà posé, le nom tapé dans la
// recherche, et le nom de famille proposé pour un enfant ou un frère ou une sœur
const [addPersonPrefill, setAddPersonPrefill] = useState(null)
const handleCreateRelative = useCallback(async (type, query = '') => {
const person = tree.selectedPerson
if (!person) return
const [first = '', ...rest] = String(query).trim().split(/\s+/)
const inheritsName = type === 'child' || type === 'sibling'
const lastName = rest.join(' ') || (inheritsName ? person.lastName || '' : '')
const personName = [person.firstName, person.lastName].filter(Boolean).join(' ')
if (!(await leaveSelectedPerson())) return
await chooseSelectedPerson(null)
setAddPersonPrefill({ nonce: Date.now(), firstName: first, lastName, relation: { type, personId: person.id, personName } })
handleOpenAddPerson(query ? 'fiche_recherche' : 'fiche')
}, [tree.selectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
// Par où l'on ajoute une personne, pour les compteurs d'édition (aucun contenu de l'arbre ne part)
const addPersonViaRef = useRef('menu')
const handleOpenAddPerson = useCallback((via) => {
addPersonViaRef.current = typeof via === 'string' ? via : 'menu'
setAddPersonError('')
// Le panneau d'ajout vit dans le mode édition : la famille y entre sans passer par le crayon
if (!editMode.isActive) editMode.activate()
@@ -1208,13 +1291,12 @@ function App() {
},
]
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
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'family', relations: (formData.relations || []).length, tree_id: opaqueTreeId })
// La personne apparaît en fantôme « Brouillon » : la famille peut en ajouter d'autres, puis envoyer une
// fois avec le bouton « Envoyer » de la barre (Patch, 25/10). Avant, la fenêtre d'envoi s'ouvrait d'office,
// seule façon de montrer qu'un ajout invisible comptait.
setAddPersonPanelVisible(false)
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
setContribSubmitError('')
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
setShowContribModal(true)
setDraftNotice(t("{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
return { proposed: true }
}
@@ -1287,6 +1369,7 @@ function App() {
})
await tree.refreshCurrentTreeGraph()
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'manager', relations: (formData.relations || []).length, tree_id: opaqueTreeId })
return newPerson
} catch (err) {
setAddPersonError(err.message || t("Erreur lors de l'ajout de la personne"))
@@ -1358,8 +1441,10 @@ function App() {
const draft = editMode.restoreDraft()
if (token && treeId) {
// Appliquer les modifications de personnes depuis le draft
const modifiedPersons = draft.modifiedPersons || {}
// Appliquer les modifications de personnes depuis le draft. Ce qui échoue reste dans le brouillon, avec un
// message : avant la 1.5.1, l'échec allait à la console et le brouillon était effacé quand même.
const failures = []
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 || ''
@@ -1384,8 +1469,9 @@ function App() {
if (Object.keys(payload).length > 0) {
try {
await updatePerson(treeId, personId, payload, token)
delete modifiedPersons[personId]
} catch (err) {
console.error('Failed to update person:', personId, err)
failures.push(getAccountErrorMessage(err))
}
}
}
@@ -1422,16 +1508,30 @@ function App() {
const hasAnnotationChanges = batchPayload.create.length > 0 || batchPayload.update.length > 0 || batchPayload.delete.length > 0
let annotationsFailed = false
if (hasAnnotationChanges) {
try {
await batchAnnotations(treeId, batchPayload, token)
} catch (err) {
console.error('Failed to submit annotations:', err)
annotationsFailed = true
failures.push(getAccountErrorMessage(err))
}
}
if (failures.length > 0) {
editMode.updateDraft({
modifiedPersons,
...(annotationsFailed ? {} : { addedAnnotations: [], modifiedAnnotations: [], deletedAnnotations: [] }),
})
editMode.saveDraft()
await tree.refreshCurrentTreeGraph({ relayout: false })
setDraftNotice(t("Une partie n'a pas été enregistrée ({error}). Elle reste dans vos modifications : réessayez.", { error: failures[0] }))
return
}
}
await tree.refreshCurrentTreeGraph({ relayout: false })
trackAppEvent('tree_edits_saved', { persons: Object.keys(draft.modifiedPersons || {}).length, annotations: (draft.addedAnnotations || []).length, tree_id: opaqueTreeId })
editMode.clearDraft()
editMode.deactivate()
} else {
@@ -1477,9 +1577,13 @@ function App() {
editMode.clearDraft()
editMode.deactivate()
// Les ajouts du brouillon (dessins, photos) restaient affichés comme acceptés : l'arbre est relu, et ce qui
// vient d'être envoyé revient en fantôme « En attente »
await tree.refreshCurrentTreeGraph({ relayout: false }).catch(() => {})
setShowContribModal(false)
setContribSubmitError('')
setContribSuccess(true)
trackAppEvent('proposal_sent', { changes: changes.length, tree_id: opaqueTreeId })
setDraftNotice('')
setTimeout(() => setContribSuccess(false), 6000)
} catch (err) {
@@ -1543,6 +1647,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' }, 'tree'),
onReject: () => contrib.handleReviewContributionSession(sessionId, { decision: 'rejected' }, 'tree'),
onOpenDetails: () => { setSelectedGhost(null); handleOpenContributionPanel() },
})
const handleContributionPasswordRemember = useCallback(async ({ password }) => {
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
if (!tree.treeContext.treeId || !accessToken) {
@@ -3176,6 +3290,12 @@ function App() {
error={addPersonError}
onSubmit={handleAddPersonSubmit}
onClose={handleCloseAddPerson}
prefill={addPersonPrefill}
onOpenPerson={(person) => {
trackAppEvent('duplicate_opened', { tree_id: opaqueTreeId })
setAddPersonPanelVisible(false)
handlePersonSelect(person)
}}
/>
{/* Modal Session Contribution (contributeurs) */}
<ContributionSessionModal
@@ -3215,7 +3335,7 @@ function App() {
)}
{contribSuccess && (
<div className="contrib-success-toast" role="status">
{t("Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.")}
{t("Merci, c'est envoyé ! C'est en pointillés sur l'arbre jusqu'à la relecture.")}
</div>
)}
{/* Navbar contextuelle */}
@@ -3274,8 +3394,17 @@ function App() {
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
selfPersonId={linkedSelfPersonId || null}
ghosts={ghosts}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{selectedGhost && !tree.selectedPerson && (
<ProposalCard
person={selectedGhost}
summary={selectedGhost.ghost?.summary || ''}
review={userRole.isAdmin && selectedGhost.ghost?.source === 'pending' ? reviewFromTree(selectedGhost.ghost.sessionId) : null}
onClose={() => setSelectedGhost(null)}
/>
)}
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
@@ -3300,6 +3429,11 @@ function App() {
onRemoveRelation={handleRemoveRelation}
onSetParentage={handleSetParentage}
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
onCreateRelative={tree.canEditCurrentTree || tree.canSubmitContribution || isDemoMode ? handleCreateRelative : null}
proposal={ghosts?.patches.get(String(tree.selectedPerson.id)) || null}
proposalReview={userRole.isAdmin && ghosts?.patches.get(String(tree.selectedPerson.id))?.ghost?.source === 'pending'
? reviewFromTree(ghosts.patches.get(String(tree.selectedPerson.id)).ghost.sessionId)
: null}
relationActionLoading={relationFeedback.loading}
relationActionSuccess={relationFeedback.success}
relationActionError={relationFeedback.error}
+12
View File
@@ -404,6 +404,18 @@ export async function reviewContributionSession(treeId, sessionId, payload, toke
return data
}
// État d'une proposition envoyée (en attente, acceptée, refusée) : la famille retire ses fantômes une fois relue
export async function fetchContributionStatus(treeId, sessionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/status`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.status || null
}
export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
@@ -120,3 +120,8 @@
/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */
.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; }
/* Doublon probable (1.5.1) : une personne du même nom est déjà dans l'arbre */
.apn-duplicate { display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--pz-a200); border-radius: 10px; background: var(--pz-a50); }
.apn-duplicate-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0; font: 500 13.5px var(--pz-sans); color: var(--pz-ink); }
.apn-duplicate-hint { margin: 0; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
+38 -1
View File
@@ -7,6 +7,7 @@ import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { SEX_CHOICES } from '../utils/personSex.js'
import { initialOf } from '../utils/initial.js'
import { findLikelyDuplicates } from '../utils/duplicates.js'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
@@ -33,7 +34,11 @@ function withEmphasis(text) {
* @param {Function} props.onSubmit - Handler soumission (formData)
* @param {Function} props.onClose - Handler fermeture
*/
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
/**
* @param {Object} props.prefill - ouverture depuis une fiche (1.5.1) : { nonce, firstName, lastName, relation: { type, personId, personName } }
* @param {Function} props.onOpenPerson - « Ouvrir sa fiche » sous l'alerte de doublon
*/
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose, prefill = null, onOpenPerson = null }) {
const [firstName, setFirstName] = useState('')
// Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche
const [unknownFirstName, setUnknownFirstName] = useState(false)
@@ -61,6 +66,23 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const firstNameRef = useRef(null)
const panelRef = useRef(null)
// Ouvert depuis une fiche : le lien avec la personne est déjà là, et le nom de famille proposé pour un enfant
// ou un frère ou une sœur (modifiable). Avant, il fallait rechercher à nouveau la personne qu'on venait de quitter.
useEffect(() => {
if (!prefill?.nonce) return
setFirstName(prefill.firstName || '')
setUnknownFirstName(false)
setLastName(prefill.lastName || '')
setTouched({})
if (prefill.relation?.personId != null) {
setSelectedRelationType(prefill.relation.type)
setRelations([{ personId: prefill.relation.personId, personName: prefill.relation.personName || '', type: prefill.relation.type }])
}
}, [prefill?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps
// Doublon probable : même prénom et même nom qu'une personne de l'arbre
const duplicates = unknownFirstName ? [] : findLikelyDuplicates(persons, firstName, lastName)
// Focus sur Prénom à l'ouverture
useEffect(() => {
if (visible) {
@@ -251,6 +273,21 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
)}
{lastNameMissing && <p className="apn-error-text">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
{duplicates.length > 0 && (
<div className="apn-duplicate" role="status">
{duplicates.map((p) => (
<p key={p.id} className="apn-duplicate-line">
<span>{p.birthYear
? t('{name} ({year}) est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim(), year: p.birthYear })
: t('{name} est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim() })}</span>
{onOpenPerson && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onOpenPerson(p)}>{t('Ouvrir sa fiche')}</button>
)}
</p>
))}
<p className="apn-duplicate-hint">{t('Si c’est une autre personne, ajoutez-la quand même.')}</p>
</div>
)}
<div className="pz-field">
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
+18 -5
View File
@@ -4,6 +4,7 @@ import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STR
import { easeOutCubic } from './galaxy/utils'
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
@@ -43,7 +44,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => 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,
+7
View File
@@ -304,3 +304,10 @@
}
.pc-quick-edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .pc-quick-edit:hover { background: var(--pz-line-soft); } }
/* Ajouter un proche depuis la fiche en lecture (1.5.1) */
.pc-quick-add { display: flex; flex-wrap: wrap; gap: 6px; }
.pc-quick-add .pz-btn { gap: 4px; }
/* « Créer « Jeanne » comme parent » sous la recherche d'un proche */
.pc-search-create .pc-rel-avatar { color: var(--pz-a700); background: var(--pz-a50); }
.pc-search-create em { color: var(--pz-muted); font-style: normal; }
+52 -5
View File
@@ -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,11 @@ 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,
// Créer un proche qui n'est pas encore dans l'arbre (1.5.1) : (type, nom tapé) ouvre l'ajout déjà rempli
onCreateRelative = null,
relationActionLoading = false,
relationActionSuccess = '',
relationActionError = '',
@@ -613,14 +619,15 @@ const PersonCard = ({
ageLabel && { key: 'age', node: ageLabel, nowrap: true },
].filter(Boolean)
const renderRelationRow = (target, relationType, meta) => (
// removable : un frère ou une sœur passe par ses parents, il ne se retire pas d'ici
const renderRelationRow = (target, relationType, meta, removable = true) => (
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
<span className="pc-rel-avatar" aria-hidden="true">
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
</span>
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
{meta && <span className="pc-rel-meta">{meta}</span>}
<button
{removable && <button
type="button"
className="pz-btn pz-btn--ghost pz-btn--icon pc-rel-remove"
aria-label={t('Retirer {name}', { name: target ? fullNameLabel(target) : '' })}
@@ -628,7 +635,7 @@ const PersonCard = ({
disabled={relationActionLoading}
>
<X size={15} strokeWidth={2} />
</button>
</button>}
</div>
)
@@ -642,6 +649,9 @@ const PersonCard = ({
onSelect={(p) => handleRelationAdd(p, relationType)}
onClose={() => { setActiveRelationSearch(null); setRelationSearchQuery('') }}
placeholder={placeholder}
// La recherche ne trouvait que les personnes déjà dans l'arbre : sans résultat, on s'arrêtait là
onCreate={onCreateRelative ? (query) => { setActiveRelationSearch(null); setRelationSearchQuery(''); onCreateRelative(relationType, query) } : null}
createLabel={relationType === 'parent' ? t('comme parent') : relationType === 'child' ? t('comme enfant') : t('comme conjoint·e')}
/>
) : (
<button type="button" className="pc-rel-add" onClick={() => setActiveRelationSearch(relationType)} disabled={relationActionLoading}>
@@ -785,6 +795,7 @@ const PersonCard = ({
</label>
)}
</header>
{!editMode && proposal && <ProposalBanner patch={proposal} review={proposalReview} />}
{avatarActionError && <div className="pz-error pc-inline-msg">{avatarActionError}</div>}
@@ -1041,6 +1052,18 @@ const PersonCard = ({
</section>
)}
{/* Ajouter un proche sans passer par « Modifier » puis l'onglet Relations (1.5.1) */}
{!editMode && onCreateRelative && (
<div className="pc-quick-add" role="group" aria-label={t('Ajouter un proche')}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('parent', '')}><Plus size={14} strokeWidth={2} />{t('Parent')}</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('spouse', '')}><Plus size={14} strokeWidth={2} />{t('Conjoint·e')}</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('child', '')}><Plus size={14} strokeWidth={2} />{t('Enfant')}</button>
{parentsList.length > 0 && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('sibling', '')}><Plus size={14} strokeWidth={2} />{t('Frère ou sœur')}</button>
)}
</div>
)}
{(editMode ? notesShown : person.note) && (
<section className="pc-section" data-pc-field="note">
<h3 className="pz-eyebrow">{t('Notes')}</h3>
@@ -1102,6 +1125,24 @@ const PersonCard = ({
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
</section>
{onCreateRelative && (
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Frères et sœurs')}</h3>
{siblingsList.length > 0
? siblingsList.map(c => renderRelationRow(c, 'sibling', null, false))
: <p className="pc-rel-empty">{t('Aucun frère ni sœur')}</p>}
{/* Un frère ou une sœur reçoit les mêmes parents : sans parent, on le dit avant d'ouvrir l'ajout */}
{parentsList.length > 0 ? (
<button type="button" className="pc-rel-add" onClick={() => onCreateRelative('sibling', '')}>
<Plus size={15} strokeWidth={2} />
{t('Ajouter un frère ou une sœur')}
</button>
) : (
<p className="pc-rel-empty">{t("Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.")}</p>
)}
</section>
)}
</div>
)}
</div>
@@ -1239,7 +1280,7 @@ const PersonCard = ({
* Recherche en ligne pour ajouter un lien (parent, conjoint·e, enfant)
*/
const RelationSearchInline = forwardRef(function RelationSearchInline(
{ query, onQueryChange, results, onSelect, onClose, placeholder },
{ query, onQueryChange, results, onSelect, onClose, placeholder, onCreate = null, createLabel = '' },
ref
) {
const filtered = results.slice(0, 6)
@@ -1281,9 +1322,15 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
</li>
))}
</ul>
) : query ? (
) : query && !onCreate ? (
<p className="pc-rel-empty">{t('Aucune personne ne correspond')}</p>
) : null}
{onCreate && query.trim() && (
<button type="button" className="pc-search-item pc-search-create" onClick={() => onCreate(query.trim())}>
<span className="pc-rel-avatar" aria-hidden="true"><Plus size={14} strokeWidth={2.2} /></span>
<span className="pc-rel-name">{t('Créer « {name} »', { name: query.trim() })} <em>{createLabel}</em></span>
</button>
)}
</div>
)
})
+87
View File
@@ -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;
}
+126
View File
@@ -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 (
<div className="proposal-actions">
{error && <p className="pz-error proposal-error" role="alert">{error}</p>}
<div className="proposal-buttons">
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={onAccept} disabled={busy}>
<PzBusy busy={busy} busyLabel={t('Relecture en cours')}>{t('Accepter')}</PzBusy>
</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onReject} disabled={busy}>{t('Refuser')}</button>
{onOpenDetails && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm proposal-details" onClick={onOpenDetails} disabled={busy}>{t('Voir le détail')}</button>
)}
</div>
</div>
)
}
/**
* @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 (
<div className="person-card proposal-card" role="dialog" aria-label={t('Proposition : {name}', { name })}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
<header className="pc-head">
<div className="pc-photo-wrap">
<div className="pc-photo proposal-photo"><span className="pc-photo-initials">{initials}</span></div>
</div>
<div className="pc-id">
<p className="pz-eyebrow proposal-eyebrow">{ghost?.source === 'draft' ? t('Brouillon') : ghost?.source === 'sent' ? t('En attente') : t('Proposition')}</p>
<h2 className="pc-name">
{person?.firstName ? person.firstName : <span className="pc-name-unknown">{firstNameLabel(person)}</span>}
{person?.lastName && <>{' '}<em>{person.lastName}</em></>}
</h2>
{years && <p className="proposal-years">{years}</p>}
</div>
</header>
<div className="proposal-body">
<p className="proposal-who">{whoAndWhen(ghost)}</p>
{summary && <p className="proposal-summary">{t('Dans cette proposition : {summary}.', { summary })}</p>}
<ReviewActions ghost={ghost} review={review} />
</div>
</div>
)
}
/**
* 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 (
<section className="proposal-banner" aria-label={t('Modification proposée')}>
<p className="proposal-who">{whoAndWhen(patch.ghost)}</p>
{patch.deleted ? (
<p className="proposal-change">{t('Supprimer cette personne de l’arbre')}</p>
) : (
<ul className="proposal-fields">
{entries.map(([key, value]) => (
<li key={key}><span className="proposal-field">{FIELD_LABELS[key]}</span> {formatValue(key, value)}</li>
))}
</ul>
)}
<ReviewActions ghost={patch.ghost} review={review} />
</section>
)
}
+3 -1
View File
@@ -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),
})
})
+85 -11
View File
@@ -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 {
@@ -1090,6 +1098,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
@@ -1129,6 +1139,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)
// ============================================================
@@ -1149,7 +1223,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)
@@ -1164,7 +1238,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'
@@ -1178,7 +1252,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'
@@ -1322,7 +1396,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
@@ -193,7 +193,8 @@ export function useGalaxyInteractions({
}, [canvasRef, mouseWorldPosRef, hoveredNodeIdRef, hoveredMediaKeyRef])
const handleWheel = useCallback((e) => {
if (e.cancelable) e.preventDefault()
// Le défilement de la page est déjà empêché par l'écouteur natif non passif (Galaxy) : ici, React écoute en
// passif, et un preventDefault ne faisait qu'écrire une erreur dans la console à chaque cran de molette
const rect = canvasRef.current.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
@@ -0,0 +1,70 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import PersonCard from './PersonCard.jsx'
import ProposalCard, { ProposalBanner } from './ProposalCard.jsx'
import { findLikelyDuplicates } from '../utils/duplicates.js'
import { summarizeChanges } from '../utils/proposalSummary.js'
// 1.5.1 : propositions sur l'arbre (fantômes) et ajout d'un proche depuis la fiche. Les droits de relecture sont
// tenus par le serveur (backend/test/propositions-fantomes.db.test.ts) ; ici, ce que chacun voit.
const noop = () => {}
const ghostPerson = (source) => ({ id: 'ghost:s1:tmp:a', firstName: 'Lucie', lastName: 'Martin', birthYear: 1990, ghost: { source, sessionId: 's1', label: 'Cousine', createdAt: '2026-10-25T10:00:00Z' } })
const review = { busy: false, error: '', onAccept: noop, onReject: noop, onOpenDetails: noop }
describe('carte d’une proposition', () => {
it('qui gère l’arbre peut accepter, refuser ou voir le détail', () => {
const html = renderToStaticMarkup(<ProposalCard person={ghostPerson('pending')} summary="1 personne, 1 lien" review={review} onClose={noop} />)
expect(html).toContain('Proposé par Cousine')
expect(html).toContain('Accepter')
expect(html).toContain('Refuser')
// t() pose une espace fine insécable devant les deux-points
expect(html).toMatch(/Dans cette proposition\s:\s1 personne, 1 lien\./)
})
it('la famille lit seulement qu’elle attend, sans bouton de relecture', () => {
const html = renderToStaticMarkup(<ProposalCard person={ghostPerson('sent')} review={review} onClose={noop} />)
expect(html).toContain('en attente de relecture')
expect(html).not.toContain('Accepter')
expect(renderToStaticMarkup(<ProposalCard person={ghostPerson('draft')} onClose={noop} />)).toContain('Pas encore envoyé')
})
it('le bandeau d’une fiche existante liste les champs proposés', () => {
const html = renderToStaticMarkup(<ProposalBanner patch={{ fields: { birthPlace: 'Lille', deathDate: '1950-01-01' }, ghost: { source: 'pending', label: 'Cousine' } }} review={review} />)
expect(html).toContain('Lieu de naissance')
expect(html).toContain('Lille')
expect(html).toContain('1950')
expect(html).toContain('Accepter')
})
it('le résumé compte ce que contient la proposition', () => {
expect(summarizeChanges([
{ entityType: 'person', action: 'create' },
{ entityType: 'parent_child_link', action: 'create' },
{ entityType: 'union', action: 'create' },
{ entityType: 'annotation', action: 'create' },
])).toBe("1 personne, 2 liens, 1 ajout sur l'arbre")
})
})
describe('ajouter un proche depuis la fiche', () => {
const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true }
it('la fiche en lecture propose parent, conjoint·e et enfant quand App le permet ; sinon rien', () => {
const html = renderToStaticMarkup(<PersonCard person={person} onSave={noop} onCreateRelative={noop} />)
expect(html).toContain('pc-quick-add')
expect(html).toContain('Conjoint·e')
// Sans parent connu, pas de frère ou sœur à rattacher
expect(html).not.toContain('Frère ou sœur')
expect(renderToStaticMarkup(<PersonCard person={person} onSave={noop} />)).not.toContain('pc-quick-add')
})
it('le doublon probable ignore accents, casse et espaces, et regarde aussi le nom de naissance', () => {
const persons = [
{ id: 'a', firstName: 'Hélène', lastName: 'Dupont' },
{ id: 'b', firstName: 'Helene', lastName: 'Martin', birthName: 'Dupont' },
{ id: 'c', firstName: 'Hélène', lastName: 'Durand' },
{ id: 'g', firstName: 'Hélène', lastName: 'Dupont', ghost: { source: 'pending' } },
]
expect(findLikelyDuplicates(persons, ' helene ', 'DUPONT').map((p) => p.id)).toEqual(['a', 'b'])
expect(findLikelyDuplicates(persons, 'Hélène', '')).toEqual([])
})
})
+52 -35
View File
@@ -1,11 +1,13 @@
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 { toOpaqueTreeId, trackAppEvent } from '../utils/analytics'
import { t } from '../i18n/index.js'
const EMPTY_CONTRIB_STATE = {
@@ -41,6 +43,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 +151,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) => ({
@@ -192,7 +230,8 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
})
}
const handleReviewContributionSession = async (sessionId, reviewPayload) => {
// from : « tree » (depuis le fantôme) ou « panel », pour les compteurs d'édition
const handleReviewContributionSession = async (sessionId, reviewPayload, from = 'panel') => {
if (!canEditCurrentTree || !treeContext.treeId || !treeContext.accessToken) {
return
}
@@ -211,38 +250,8 @@ 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)
trackAppEvent('proposal_reviewed', { decision: reviewPayload.decision, from, partial: Boolean(reviewPayload.changeDecisions?.length), tree_id: toOpaqueTreeId(treeContext.treeId) })
await refreshCurrentTreeGraph()
await refreshContributionSessions()
@@ -252,6 +261,12 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
reviewingSessionId: '',
}))
} catch (error) {
// Déjà relue ailleurs (un autre onglet, un co-gestionnaire) : l'arbre et la liste se remettent à jour, le
// fantôme part avec elle
if (error?.payload?.error === 'contribution_session_already_reviewed') {
await refreshCurrentTreeGraph().catch(() => {})
await refreshContributionSessions()
}
setContribState((current) => ({
...current,
reviewingSessionId: '',
@@ -271,5 +286,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
handleSubmitContributionUpdatePerson,
handleSubmitContributionDeletePerson,
handleReviewContributionSession,
sentProposals,
refreshSentProposals,
}
}
+5
View File
@@ -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
+2 -2
View File
@@ -14,13 +14,13 @@ export default {
// App.jsx : ajout de personnes et contributions
"Ajoutez d'abord un parent à {name} pour pouvoir lui rattacher un frère ou une sœur.": 'Add a parent to {name} first, so you can link a sibling to them.',
"Ajoutez d'abord un parent à cette personne pour pouvoir lui rattacher un frère ou une sœur.": 'Add a parent to this person first, so you can link a sibling to them.',
'{name} est dans vos modifications. Envoyez-les quand vous avez fini.': '{name} is in your changes. Send them when you are done.',
"{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.": "{name} is on the tree as a draft. Send when you're done.",
"Erreur lors de l'ajout de la personne": 'Could not add this person',
'Aucune modification à soumettre.': 'No changes to send.',
'Contribution de {name} · {date}': 'Contribution from {name} · {date}',
'Contribution de la famille · {date}': 'Contribution from the family · {date}',
"Erreur lors de l'envoi des contributions": 'Could not send your contributions',
"Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.": "Thanks, it's sent! It will show up in the tree once it's reviewed.",
"Merci, c'est envoyé ! C'est en pointillés sur l'arbre jusqu'à la relecture.": "Thanks, it's sent! It shows dotted on the tree until it's reviewed.",
// App.jsx : mot de passe de partage
'Session invalide.': 'Invalid session.',
+35
View File
@@ -195,4 +195,39 @@ 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',
"La photo n'a pas pu être envoyée. Réessayez.": "The photo couldn't be sent. Try again.",
"Une partie n'a pas été enregistrée ({error}). Elle reste dans vos modifications : réessayez.": "Some changes weren't saved ({error}). They're still in your edits: try again.",
'Cette photo est trop lourde. Essayez une photo plus petite.': 'This photo is too large. Try a smaller one.',
'Beaucoup de photos attendent déjà la relecture sur cet arbre. Réessayez quand la personne qui le gère les aura vues.': "Many photos are already waiting for review on this tree. Try again once the person who manages it has seen them.",
'Ce fichier n’est pas une photo que Parenthèse sait afficher (JPEG, PNG, WebP ou GIF).': "This file isn't a photo Parenthèse can show (JPEG, PNG, WebP or GIF).",
'Cette proposition a déjà été relue, peut-être par une autre personne qui gère l’arbre.': 'This suggestion has already been reviewed, maybe by another person who manages the tree.',
}
+16
View File
@@ -495,4 +495,20 @@ export default {
'Chargement de la photo': 'Loading photo',
'Impossible d’afficher cette photo.': 'Unable to display this photo.',
'Les photos venues d’Immich ne se recadrent pas pour l’instant.': 'Photos from Immich cannot be cropped for now.',
// ─── Ajouter un proche depuis la fiche (1.5.1) ───
'{name} ({year}) est déjà dans l’arbre.': '{name} ({year}) is already in the tree.',
'{name} est déjà dans l’arbre.': '{name} is already in the tree.',
'Ouvrir sa fiche': 'Open their profile',
'Si c’est une autre personne, ajoutez-la quand même.': "If it's someone else, add them anyway.",
'comme parent': 'as a parent',
'comme enfant': 'as a child',
'comme conjoint·e': 'as a partner',
'Ajouter un proche': 'Add a relative',
'Frère ou sœur': 'Sibling',
'Frères et sœurs': 'Siblings',
'Aucun frère ni sœur': 'No siblings',
'Ajouter un frère ou une sœur': 'Add a sibling',
"Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.": 'Add a parent first to link a sibling.',
'Créer « {name} »': 'Create “{name}”',
}
+14
View File
@@ -0,0 +1,14 @@
// Doublon probable à l'ajout d'une personne (1.5.1) : même prénom, et même nom ou nom de naissance, sans tenir
// compte des accents, de la casse ni des espaces en trop. Trois au plus.
function norm(value) {
return String(value || '').normalize('NFD').replace(/\p{M}/gu, '').trim().toLowerCase().replace(/\s+/g, ' ')
}
export function findLikelyDuplicates(persons = [], firstName = '', lastName = '') {
const first = norm(firstName)
const last = norm(lastName)
if (!first || !last) return []
return persons
.filter((p) => !p.ghost && norm(p.firstName) === first && (norm(p.lastName) === last || norm(p.birthName) === last))
.slice(0, 3)
}
+16
View File
@@ -64,6 +64,22 @@ export function getAccountErrorMessage(error) {
return t('Le format de fichier ne correspond pas au type de média choisi.')
}
if (errorCode === 'contribution_session_already_reviewed') {
return t('Cette proposition a déjà été relue, peut-être par une autre personne qui gère l’arbre.')
}
if (errorCode === 'photo_too_large') {
return t('Cette photo est trop lourde. Essayez une photo plus petite.')
}
if (errorCode === 'too_many_pending_photos') {
return t("Beaucoup de photos attendent déjà la relecture sur cet arbre. Réessayez quand la personne qui le gère les aura vues.")
}
if (errorCode === 'invalid_annotation_photo_mime_type') {
return t('Ce fichier n’est pas une photo que Parenthèse sait afficher (JPEG, PNG, WebP ou GIF).')
}
if (errorCode === 'storage_quota_exceeded') {
return t("L'arbre n'a plus de place pour de nouveaux souvenirs. Son propriétaire peut en supprimer.")
}
+184
View File
@@ -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<typeof ghostsFromChanges>} 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:'))
}
+61
View File
@@ -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)
})
})
+24
View File
@@ -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(', ')
}
+53
View File
@@ -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))
}