From 9d34796d11728586514e53bdbe69eacf63048804 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sat, 17 Oct 2026 11:38:35 +0200 Subject: [PATCH] feat(vie): ajouter, modifier et supprimer un moment depuis l'onglet Vie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un bouton « Ajouter un moment » pour les administrateurs, une fiche courte (quoi, quand, environ, ou, une photo), date exacte, fin, source et note sous « Plus de details ». La photo devient un souvenir de la personne, rattache au moment (life_events.mediaId, migration additive). Toucher un moment l'ouvre pour le modifier ou le supprimer. Co-Authored-By: Claude Opus 5.5 --- .../20261017_001_moment_photo/migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/routes/graph.ts | 2 +- backend/src/routes/trees.ts | 121 ++++++++++ backend/test/api-routes.test.ts | 53 +++++ frontend/src/App.jsx | 1 + frontend/src/api/treeApi.js | 24 ++ frontend/src/components/LifeTimeline.css | 16 ++ frontend/src/components/LifeTimeline.jsx | 23 +- frontend/src/components/MomentSheet.css | 45 ++++ frontend/src/components/MomentSheet.jsx | 224 ++++++++++++++++++ frontend/src/components/PersonCard.jsx | 28 ++- frontend/src/hooks/useMediaManager.js | 29 +++ frontend/src/i18n/en/fiche.js | 25 ++ frontend/src/utils/lifeTimeline.js | 19 +- 15 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 backend/prisma/migrations/20261017_001_moment_photo/migration.sql create mode 100644 frontend/src/components/MomentSheet.css create mode 100644 frontend/src/components/MomentSheet.jsx diff --git a/backend/prisma/migrations/20261017_001_moment_photo/migration.sql b/backend/prisma/migrations/20261017_001_moment_photo/migration.sql new file mode 100644 index 0000000..1557622 --- /dev/null +++ b/backend/prisma/migrations/20261017_001_moment_photo/migration.sql @@ -0,0 +1,2 @@ +-- Photo rattachée à un événement de vie (souvenir de la personne), facultative +ALTER TABLE "life_events" ADD COLUMN "mediaId" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 43660a1..aaa17fc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -424,6 +424,7 @@ model LifeEvent { description String? notes String? sources String? + mediaId String? displayOrder Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/routes/graph.ts b/backend/src/routes/graph.ts index a4c9e2e..8b7364e 100644 --- a/backend/src/routes/graph.ts +++ b/backend/src/routes/graph.ts @@ -203,7 +203,7 @@ export const graphRoutes: FastifyPluginAsync = async (app) => { where: { treeId, deletedAt: null, OR: [{ personId }, ...(unionIds.length ? [{ unionId: { in: unionIds } }] : [])] }, select: { id: true, personId: true, unionId: true, type: true, label: true, date: true, dateEnd: true, datePrecision: true, - dateText: true, place: true, description: true, notes: true, sources: true, displayOrder: true, + dateText: true, place: true, description: true, notes: true, sources: true, mediaId: true, displayOrder: true, }, orderBy: [{ date: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'asc' }], }) diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 86c8eb2..31278aa 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -156,6 +156,49 @@ const patchParentChildLinkSchema = z message: 'at_least_one_field_required', }) +// Événements de vie saisis dans l'onglet Vie (« Ajouter un moment »). Naissance, décès et mariage gardent +// leur date sur la personne ou l'union et ne passent pas par ici. +const LIFE_EVENT_PRECISIONS = ['about', 'estimated', 'calculated', 'before', 'after', 'between', 'from', 'to', 'interpreted'] as const +const RESERVED_LIFE_EVENT_TYPES = new Set(['birth', 'death', 'marriage']) +const lifeEventDateRegex = /^\d{4}(-\d{2}-\d{2})?$/ +const lifeEventPayloadSchema = z.object({ + type: z.string().trim().min(1).max(40).regex(/^[a-z_]+$/).refine((type) => !RESERVED_LIFE_EVENT_TYPES.has(type)).default('custom'), + label: z.string().trim().max(120).nullable().optional(), + description: z.string().trim().max(1000).nullable().optional(), + date: z.string().regex(lifeEventDateRegex).nullable().optional(), + dateEnd: z.string().regex(lifeEventDateRegex).nullable().optional(), + datePrecision: z.enum(LIFE_EVENT_PRECISIONS).nullable().optional(), + place: z.string().trim().max(300).nullable().optional(), + notes: z.string().trim().max(4000).nullable().optional(), + sources: z.string().trim().max(4000).nullable().optional(), + mediaId: z.string().min(1).nullable().optional(), +}) +const lifeEventParamsSchema = z.object({ id: z.string().min(1), eventId: z.string().min(1) }) + +const lifeEventDate = (value: string | null | undefined) => { + if (value === undefined) return undefined + if (!value) return null + return new Date(`${value.length === 4 ? `${value}-01-01` : value}T00:00:00.000Z`) +} +const emptyToNull = (value: string | null | undefined) => (value === undefined ? undefined : value?.trim() ? value.trim() : null) + +function lifeEventData(payload: z.infer) { + return { + type: payload.type, + label: emptyToNull(payload.label), + description: emptyToNull(payload.description), + date: lifeEventDate(payload.date), + dateEnd: lifeEventDate(payload.dateEnd), + datePrecision: payload.datePrecision, + // Saisi dans l'app : pas de texte GEDCOM d'origine à garder + dateText: null, + place: emptyToNull(payload.place), + notes: emptyToNull(payload.notes), + sources: emptyToNull(payload.sources), + mediaId: payload.mediaId, + } +} + const personParamsSchema = z.object({ id: z.string().min(1), personId: z.string().min(1), @@ -2006,6 +2049,84 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { }) // Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction + // Photo rattachée : un souvenir de cette personne, dans cet arbre + const checkLifeEventMedia = async (treeId: string, personId: string, mediaId: string | null | undefined) => { + if (!mediaId) return true + const media = await app.prisma.mediaItem.findFirst({ where: { id: mediaId, treeId, personId, deletedAt: null }, select: { id: true } }) + return Boolean(media) + } + + app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = personParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + const payload = lifeEventPayloadSchema.safeParse(request.body ?? {}) + if (!payload.success) return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + if (!payload.data.label?.trim() && !payload.data.description?.trim() && payload.data.type === 'custom') { + return reply.code(400).send({ error: 'invalid_payload' }) + } + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) + + const person = await app.prisma.person.findFirst({ where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, select: { id: true } }) + if (!person) return reply.code(404).send({ error: 'person_not_found' }) + if (!(await checkLifeEventMedia(params.data.id, params.data.personId, payload.data.mediaId))) { + return reply.code(400).send({ error: 'invalid_media_reference' }) + } + + const event = await app.prisma.lifeEvent.create({ + data: { treeId: params.data.id, personId: params.data.personId, ...lifeEventData(payload.data) }, + }) + return reply.code(201).send({ event: { id: event.id } }) + }) + + app.patch('/trees/:id/life/events/:eventId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = lifeEventParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + const payload = lifeEventPayloadSchema.partial().safeParse(request.body ?? {}) + if (!payload.success) return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) + + const existing = await app.prisma.lifeEvent.findFirst({ + where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null }, + select: { personId: true, type: true }, + }) + if (!existing || !existing.personId || RESERVED_LIFE_EVENT_TYPES.has(existing.type)) { + return reply.code(404).send({ error: 'event_not_found' }) + } + if (!(await checkLifeEventMedia(params.data.id, existing.personId, payload.data.mediaId))) { + return reply.code(400).send({ error: 'invalid_media_reference' }) + } + + // Le type ne change pas après coup (le schéma partiel remettrait sa valeur par défaut) + const data = lifeEventData({ ...payload.data, type: existing.type } as z.infer) + await app.prisma.lifeEvent.update({ where: { id: params.data.eventId }, data }) + return reply.send({ ok: true }) + }) + + app.delete('/trees/:id/life/events/:eventId', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) return + const params = lifeEventParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) + + const deleted = await app.prisma.lifeEvent.updateMany({ + where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } }, + data: { deletedAt: new Date() }, + }) + if (deleted.count === 0) return reply.code(404).send({ error: 'event_not_found' }) + return reply.code(204).send() + }) + app.put('/trees/:id/links/order', async (request, reply) => { const user = requireUser(request, reply) if (!user) { diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 23fbdfd..ad29829 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -71,6 +71,9 @@ const { prismaMock } = vi.hoisted(() => ({ updateMany: vi.fn(), }, lifeEvent: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), findMany: vi.fn().mockResolvedValue([]), createMany: vi.fn().mockResolvedValue({ count: 0 }), updateMany: vi.fn().mockResolvedValue({ count: 0 }), @@ -628,6 +631,56 @@ describe('Graph routes', () => { }) }) +describe('Événements de vie saisis dans l’onglet Vie', () => { + it('un admin ajoute un moment avec sa photo, le modifie sans changer son type, le supprime', async () => { + const app = createApp() + const token = await createUserToken(app) + const auth = { authorization: `Bearer ${token}` } + prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'm-1' }) + prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1' }) + + const created = await app.inject({ + method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, + payload: { label: 'Service militaire', date: '1940', datePrecision: 'about', place: 'Limoges', mediaId: 'm-1' }, + }) + expect(created.statusCode).toBe(201) + expect(prismaMock.lifeEvent.create).toHaveBeenCalledWith({ data: expect.objectContaining({ + treeId: 'tree-1', personId: 'person-1', type: 'custom', label: 'Service militaire', + date: new Date('1940-01-01T00:00:00.000Z'), datePrecision: 'about', place: 'Limoges', mediaId: 'm-1', + }) }) + + prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', type: 'occupation' }) + const patched = await app.inject({ method: 'PATCH', url: '/trees/tree-1/life/events/e-1', headers: auth, payload: { place: '' } }) + expect(patched.statusCode).toBe(200) + expect(prismaMock.lifeEvent.update).toHaveBeenCalledWith({ where: { id: 'e-1' }, data: expect.objectContaining({ type: 'occupation', place: null }) }) + + prismaMock.lifeEvent.updateMany.mockResolvedValue({ count: 1 }) + const deleted = await app.inject({ method: 'DELETE', url: '/trees/tree-1/life/events/e-1', headers: auth }) + expect(deleted.statusCode).toBe(204) + await app.close() + }) + + it('refuse un membre non admin, une photo d’une autre personne, un moment sans titre, une naissance', async () => { + const app = createApp() + const token = await createUserToken(app) + const auth = { authorization: `Bearer ${token}` } + const post = (payload: object) => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload }) + + prismaMock.mediaItem.findFirst.mockResolvedValue(null) + expect((await post({ label: 'Mariage', mediaId: 'autre' })).statusCode).toBe(400) + expect((await post({ label: ' ' })).statusCode).toBe(400) + expect((await post({ type: 'birth', label: 'Naissance' })).statusCode).toBe(400) + + prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', type: 'birth' }) + expect((await app.inject({ method: 'PATCH', url: '/trees/tree-1/life/events/e-1', headers: auth, payload: { place: 'x' } })).statusCode).toBe(404) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null } }) + expect((await post({ label: 'Service militaire' })).statusCode).toBe(403) + expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled() + await app.close() + }) +}) + describe('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3158aff..e9a6323 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2966,6 +2966,7 @@ function App() { relationActionError={relationFeedback.error} siblingPlace={siblingPlace} onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)} + onUploadMomentPhoto={tree.canEditCurrentTree ? media.uploadMomentPhoto : undefined} /> )} {tree.selectedMedia && ( diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 3d38e56..8ad1ad0 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -67,6 +67,30 @@ export async function fetchPersonLife(treeId, personId, token) { }) } +// Moments saisis dans l'onglet Vie (administrateurs) +export async function createLifeEvent(treeId, personId, payload, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) +} + +export async function updateLifeEvent(treeId, eventId, payload, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) +} + +export async function deleteLifeEvent(treeId, eventId, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) +} + export async function listTrees(token) { const data = await apiRequest('/trees', { method: 'GET', diff --git a/frontend/src/components/LifeTimeline.css b/frontend/src/components/LifeTimeline.css index 6571d95..09b8212 100644 --- a/frontend/src/components/LifeTimeline.css +++ b/frontend/src/components/LifeTimeline.css @@ -46,3 +46,19 @@ .lt-failed { margin-top: 10px; } @media (prefers-reduced-motion: reduce) { .lt-src-list { animation: none; } } + +/* Moment modifiable (administrateurs) : le titre s'ouvre en fiche */ +.lt-title--edit { + appearance: none; border: 0; background: none; padding: 0; margin: 0; text-align: left; cursor: pointer; + font: inherit; color: inherit; border-radius: 4px; text-decoration: underline; text-decoration-color: transparent; + text-underline-offset: 3px; transition: text-decoration-color 160ms ease; +} +@media (hover: hover) and (pointer: fine) { .lt-title--edit:hover { text-decoration-color: var(--pz-a300); } } +.lt-title--edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } + +.lt-photo { + display: block; width: 112px; height: 84px; object-fit: cover; margin-top: 8px; + border-radius: var(--pz-r-s); box-shadow: 0 0 0 1px rgba(42, 38, 34, .06); +} +.lt-add { margin-top: 14px; } +.pc-empty .lt-add { margin-top: 12px; } diff --git a/frontend/src/components/LifeTimeline.jsx b/frontend/src/components/LifeTimeline.jsx index 67a2ca1..a566834 100644 --- a/frontend/src/components/LifeTimeline.jsx +++ b/frontend/src/components/LifeTimeline.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Plus } from 'lucide-react' import { t } from '../i18n/index.js' import { buildLifeTimeline } from '../utils/lifeTimeline.js' import './LifeTimeline.css' @@ -23,14 +24,22 @@ function Sources({ text }) { ) } -export default function LifeTimeline({ person, family, life, loading = false, failed = false }) { +export default function LifeTimeline({ person, family, life, loading = false, failed = false, medias = [], canEdit = false, onAdd, onEdit }) { const entries = buildLifeTimeline(person, family, life) + const mediaById = new Map(medias.map((m) => [String(m.id), m])) + const addButton = canEdit && ( + + ) if (entries.length === 0) { return (

{t('Pas encore de date connue pour {name}.', { name: person.firstName })}

{t('Les dates de naissance, d’union et de décès, et celles de sa famille, apparaîtront ici.')}

+ {addButton}
) } @@ -42,15 +51,25 @@ export default function LifeTimeline({ person, family, life, loading = false, fa
  • {entry.year ?? ''}
    -
    {entry.title}
    + {canEdit && entry.event ? ( + + ) : ( +
    {entry.title}
    + )} {entry.meta &&
    {entry.meta}
    } {entry.note &&

    {entry.note}

    } {entry.sources && } + {entry.mediaId && mediaById.get(String(entry.mediaId))?.url && ( + {entry.title} + )}
  • ))} {failed &&

    {t('Les autres événements n’ont pas pu être chargés.')}

    } + {addButton} ) } diff --git a/frontend/src/components/MomentSheet.css b/frontend/src/components/MomentSheet.css new file mode 100644 index 0000000..6b484d3 --- /dev/null +++ b/frontend/src/components/MomentSheet.css @@ -0,0 +1,45 @@ +/* Fiche « Ajouter un moment » de l'onglet Vie. Variables --pz-* : src/styles/pz.css */ + +.ms { width: min(100%, 460px); } +.ms-title { font-size: 26px; } +.ms-form { display: grid; gap: 16px; } +.ms-optional { font-weight: 400; color: var(--pz-faint); margin-left: 4px; } + +.ms-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.ms-chip { + appearance: none; border: 1px solid var(--pz-line); background: var(--pz-surface); border-radius: 999px; + padding: 5px 11px; font: 500 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; + transition: background-color 160ms ease, border-color 160ms ease, transform 140ms var(--pz-ease); +} +@media (hover: hover) and (pointer: fine) { .ms-chip:hover { border-color: var(--pz-a300); } } +.ms-chip:active { transform: scale(.97); } +.ms-chip.is-on { background: var(--pz-a50); border-color: var(--pz-a300); color: var(--pz-a700); } +.ms-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } + +.ms-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } +.ms-row > .pz-field { flex: 1 1 150px; min-width: 0; } +.ms-row > .ms-year { flex: 0 1 120px; } +.ms-check { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font: 400 14px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; } +.ms-check input { width: 16px; height: 16px; accent-color: var(--pz-a500); } + +.ms-photo-add { + display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px; + border: 1.5px dashed var(--pz-a300); border-radius: 999px; background: var(--pz-a50); + font: 500 13.5px var(--pz-sans); color: var(--pz-a700); cursor: pointer; + transition: background-color 160ms ease, transform 140ms var(--pz-ease); +} +@media (hover: hover) and (pointer: fine) { .ms-photo-add:hover { background: var(--pz-a100); } } +.ms-photo-add:active { transform: scale(.97); } +.ms-photo-current { display: flex; align-items: center; gap: 12px; } +.ms-photo-current img { width: 84px; height: 64px; object-fit: cover; border-radius: var(--pz-r-s); box-shadow: 0 0 0 1px rgba(42, 38, 34, .06); } + +.ms-more { border-top: 1px solid var(--pz-line-soft); padding-top: 12px; } +.ms-more summary { cursor: pointer; font: 500 13.5px var(--pz-sans); color: var(--pz-ink-2); list-style-position: inside; } +.ms-more summary:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; border-radius: 4px; } +.ms-more-body { display: grid; gap: 14px; margin-top: 12px; } + +.ms-actions-end { display: flex; gap: 8px; margin-left: auto; } +.ms-confirm { display: flex; gap: 8px; } +.pz-btn.ms-delete { color: var(--pz-danger); padding-left: 0; } + +@media (prefers-reduced-motion: reduce) { .ms-chip, .ms-photo-add { transition: none; } } diff --git a/frontend/src/components/MomentSheet.jsx b/frontend/src/components/MomentSheet.jsx new file mode 100644 index 0000000..548955d --- /dev/null +++ b/frontend/src/components/MomentSheet.jsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { X, ImagePlus } from 'lucide-react' +import { t } from '../i18n/index.js' +import { createLifeEvent, updateLifeEvent, deleteLifeEvent } from '../api/treeApi.js' +import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js' +import './MomentSheet.css' + +// Fiche « Ajouter un moment » de l'onglet Vie : quoi, quand, où, une photo. La date exacte, la fin, +// la source et la note restent dans « Plus de détails ». Sert aussi à modifier un moment existant. + +// Suggestions touchables : le texte exact devient le type correspondant (exporté en GEDCOM sous sa balise) +const SUGGESTIONS = [ + { label: t('Baptême'), type: 'baptism' }, + { label: t('Déménagement'), type: 'residence' }, + { label: t('Diplôme'), type: 'graduation' }, + { label: t('Service militaire'), type: 'military' }, + { label: t('Retraite'), type: 'retirement' }, +] + +const yearOf = (value) => String(value ?? '').match(/^(\d{4})/)?.[1] || '' +const exactDay = (value) => (/^\d{4}-\d{2}-\d{2}$/.test(String(value ?? '')) && !String(value).endsWith('-01-01') ? value : '') + +export default function MomentSheet({ person, event = null, treeId, token, medias = [], onUploadPhoto, onClose, onSaved }) { + const editing = Boolean(event) + const [what, setWhat] = useState(() => (event ? eventTitle(event) : '')) + const [year, setYear] = useState(() => yearOf(event?.date)) + const [approx, setApprox] = useState(() => ['about', 'estimated', 'calculated', 'between'].includes(event?.datePrecision)) + const [place, setPlace] = useState(event?.place || '') + const [day, setDay] = useState(() => exactDay(event?.date)) + const [endYear, setEndYear] = useState(() => yearOf(event?.dateEnd)) + const [sources, setSources] = useState(event?.sources || '') + const [notes, setNotes] = useState(event?.notes || '') + const [mediaId, setMediaId] = useState(event?.mediaId || null) + const [file, setFile] = useState(null) + const [preview, setPreview] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [confirmDelete, setConfirmDelete] = useState(false) + const whatRef = useRef(null) + const fileRef = useRef(null) + const hasDetails = Boolean(day || endYear || sources || notes) + + useEffect(() => { whatRef.current?.focus() }, []) + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape' && !busy) onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [busy, onClose]) + useEffect(() => { + if (!file) { setPreview(''); return undefined } + const url = URL.createObjectURL(file) + setPreview(url) + return () => URL.revokeObjectURL(url) + }, [file]) + + const linkedMedia = mediaId ? medias.find((m) => String(m.id) === String(mediaId)) : null + const yearValid = !year || /^\d{4}$/.test(year) + const endValid = !endYear || (/^\d{4}$/.test(endYear) && (!year || Number(endYear) >= Number(year))) + const canSave = what.trim().length > 0 && yearValid && endValid && !busy + + const buildPayload = (photoId) => { + const text = what.trim() + const suggestion = SUGGESTIONS.find((s) => s.label.toLowerCase() === text.toLowerCase()) + const type = editing ? event.type : (suggestion?.type || 'custom') + const textField = DESCRIBED_TYPES.includes(type) ? 'description' : 'label' + // Une suggestion reprise telle quelle n'a pas besoin de libellé : le type suffit + const textValue = !editing && suggestion ? null : text + const start = day || year || null + let datePrecision = null + if (start && endYear) datePrecision = approx ? 'between' : 'from' + else if (start && approx && !day) datePrecision = 'about' + return { + ...(editing ? {} : { type }), + [textField]: textValue, + ...(textField === 'description' ? { label: null } : {}), + date: start, + dateEnd: start && endYear ? endYear : null, + datePrecision, + place: place.trim() || null, + sources: sources.trim() || null, + notes: notes.trim() || null, + mediaId: photoId, + } + } + + const save = async () => { + if (!canSave) return + setBusy(true) + setError('') + try { + let photoId = mediaId + if (file && onUploadPhoto) { + const created = await onUploadPhoto(person.id, file, [what.trim(), year].filter(Boolean).join(', ')) + photoId = created?.id || null + } + const payload = buildPayload(photoId) + if (editing) await updateLifeEvent(treeId, event.id, payload, token) + else await createLifeEvent(treeId, person.id, payload, token) + onSaved() + } catch (err) { + setError(err?.message && !/^request_failed|^invalid_/.test(err.message) ? err.message : t('L’enregistrement n’a pas abouti. Réessayez dans un instant.')) + setBusy(false) + } + } + + const remove = async () => { + setBusy(true) + setError('') + try { + await deleteLifeEvent(treeId, event.id, token) + onSaved() + } catch { + setError(t('La suppression n’a pas abouti. Réessayez dans un instant.')) + setBusy(false) + } + } + + return createPortal( +
    { if (e.target === e.currentTarget && !busy) onClose() }}> +
    + +
    +

    + {editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: person.firstName })} +

    +
    + +
    { e.preventDefault(); save() }}> +
    + + setWhat(e.target.value)} placeholder={t('Service militaire, premier emploi, départ pour Paris…')} /> + {!editing && ( +
    + {SUGGESTIONS.map((s) => ( + + ))} +
    + )} +
    + +
    +
    + + setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} /> +
    + +
    + +
    + + setPlace(e.target.value)} placeholder={t('Ville ou adresse')} /> +
    + +
    + {(preview || linkedMedia?.url) ? ( +
    + + +
    + ) : onUploadPhoto && ( + + )} + { setFile(e.target.files?.[0] || null); e.target.value = '' }} /> +
    + +
    + {t('Plus de détails')} +
    +
    +
    + + { setDay(e.target.value); if (e.target.value) setYear(e.target.value.slice(0, 4)) }} /> +
    +
    + + setEndYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!endValid || undefined} /> +
    +
    +
    + +