feat(vie): ajouter, modifier et supprimer un moment depuis l'onglet Vie

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-17 11:38:35 +02:00
1 parent cd147e9387
commit 9d34796d11
15 files changed
+604 -9

No files matched your search

@@ -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;
+1
View File
@@ -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
+1 -1
View File
@@ -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' }],
})
+121
View File
@@ -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<typeof lifeEventPayloadSchema>) {
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<typeof lifeEventPayloadSchema>)
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) {
+53
View File
@@ -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()
+1
View File
@@ -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 && (
+24
View File
@@ -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',
+16
View File
@@ -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; }
+21 -2
View File
@@ -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 && (
<button type="button" className="pz-btn pz-btn--soft pz-btn--block lt-add" onClick={onAdd}>
<Plus size={16} strokeWidth={2} />
{t('Ajouter un moment')}
</button>
)
if (entries.length === 0) {
return (
<div className="pc-empty">
<p className="pz-sub">{t('Pas encore de date connue pour {name}.', { name: person.firstName })}</p>
<p className="pz-small">{t('Les dates de naissance, d’union et de décès, et celles de sa famille, apparaîtront ici.')}</p>
{addButton}
</div>
)
}
@@ -42,15 +51,25 @@ export default function LifeTimeline({ person, family, life, loading = false, fa
<li key={entry.key} className={`lt-ev lt-ev--${entry.kind}`}>
<span className="lt-year">{entry.year ?? ''}</span>
<div className="lt-body">
<div className="lt-title">{entry.title}</div>
{canEdit && entry.event ? (
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry.event)} aria-label={t('Modifier « {title} »', { title: entry.title })}>
{entry.title}
</button>
) : (
<div className="lt-title">{entry.title}</div>
)}
{entry.meta && <div className="lt-meta">{entry.meta}</div>}
{entry.note && <p className="lt-note">{entry.note}</p>}
{entry.sources && <Sources text={entry.sources} />}
{entry.mediaId && mediaById.get(String(entry.mediaId))?.url && (
<img className="lt-photo" src={mediaById.get(String(entry.mediaId)).url} alt={entry.title} loading="lazy" />
)}
</div>
</li>
))}
</ol>
{failed && <p className="pz-small lt-failed">{t('Les autres événements n’ont pas pu être chargés.')}</p>}
{addButton}
</div>
)
}
+45
View File
@@ -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; } }
+224
View File
@@ -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(
<div className="pz-overlay ms-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}>
<div className="pz-modal ms" role="dialog" aria-modal="true" aria-labelledby="ms-title">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')} disabled={busy}>
<X size={18} strokeWidth={1.8} />
</button>
<div className="pz-modal-head">
<h2 id="ms-title" className="pz-title ms-title">
{editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: person.firstName })}
</h2>
</div>
<form className="ms-form" onSubmit={(e) => { e.preventDefault(); save() }}>
<div className="pz-field">
<label htmlFor="ms-what">{t('Quoi ?')}</label>
<input id="ms-what" ref={whatRef} value={what} maxLength={120} onChange={(e) => setWhat(e.target.value)} placeholder={t('Service militaire, premier emploi, départ pour Paris…')} />
{!editing && (
<div className="ms-chips">
{SUGGESTIONS.map((s) => (
<button key={s.type} type="button" className={`ms-chip${what === s.label ? ' is-on' : ''}`} onClick={() => setWhat(s.label)}>{s.label}</button>
))}
</div>
)}
</div>
<div className="ms-row">
<div className="pz-field ms-year">
<label htmlFor="ms-year">{t('Quand ?')}</label>
<input id="ms-year" inputMode="numeric" maxLength={4} value={year} onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} />
</div>
<label className="ms-check">
<input type="checkbox" checked={approx} onChange={(e) => setApprox(e.target.checked)} disabled={Boolean(day) && !endYear} />
{t('environ')}
</label>
</div>
<div className="pz-field">
<label htmlFor="ms-place">{t('Où ?')} <span className="ms-optional">{t('facultatif')}</span></label>
<input id="ms-place" value={place} maxLength={300} onChange={(e) => setPlace(e.target.value)} placeholder={t('Ville ou adresse')} />
</div>
<div className="ms-photo">
{(preview || linkedMedia?.url) ? (
<div className="ms-photo-current">
<img src={preview || linkedMedia.url} alt="" />
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => { setFile(null); setMediaId(null) }} disabled={busy}>
{t('Retirer la photo')}
</button>
</div>
) : onUploadPhoto && (
<button type="button" className="ms-photo-add" onClick={() => fileRef.current?.click()} disabled={busy}>
<ImagePlus size={16} strokeWidth={1.8} />
{t('Une photo')} <span className="ms-optional">{t('facultatif')}</span>
</button>
)}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => { setFile(e.target.files?.[0] || null); e.target.value = '' }} />
</div>
<details className="ms-more" open={hasDetails || undefined}>
<summary>{t('Plus de détails')}</summary>
<div className="ms-more-body">
<div className="ms-row">
<div className="pz-field">
<label htmlFor="ms-day">{t('Date exacte')}</label>
<input id="ms-day" type="date" value={day} onChange={(e) => { setDay(e.target.value); if (e.target.value) setYear(e.target.value.slice(0, 4)) }} />
</div>
<div className="pz-field ms-year">
<label htmlFor="ms-end">{t('Jusqu’en')}</label>
<input id="ms-end" inputMode="numeric" maxLength={4} value={endYear} onChange={(e) => setEndYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!endValid || undefined} />
</div>
</div>
<div className="pz-field">
<label htmlFor="ms-sources">{t('Source')}</label>
<textarea id="ms-sources" rows={2} value={sources} maxLength={4000} onChange={(e) => setSources(e.target.value)} placeholder={t('Registre, acte, archive, témoignage… une par ligne')} />
</div>
<div className="pz-field">
<label htmlFor="ms-notes">{t('Note')}</label>
<textarea id="ms-notes" rows={2} value={notes} maxLength={4000} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
</details>
{error && <p className="pz-error" role="alert">{error}</p>}
<div className={`pz-modal-actions${editing ? ' pz-modal-actions--split' : ''}`}>
{editing && (confirmDelete ? (
<span className="ms-confirm">
<button type="button" className="pz-btn pz-btn--danger" onClick={remove} disabled={busy}>{t('Supprimer')}</button>
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setConfirmDelete(false)} disabled={busy}>{t('Garder')}</button>
</span>
) : (
<button type="button" className="pz-btn pz-btn--ghost ms-delete" onClick={() => setConfirmDelete(true)} disabled={busy}>{t('Supprimer ce moment')}</button>
))}
<span className="ms-actions-end">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={busy}>{t('Annuler')}</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canSave}>
{busy ? t('Enregistrement…') : editing ? t('Enregistrer') : t('Ajouter')}
</button>
</span>
</div>
</form>
</div>
</div>,
document.body,
)
}
+27 -1
View File
@@ -4,6 +4,7 @@ import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Sparkles, Chevro
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
import MomentSheet from './MomentSheet.jsx'
import { fetchImmichConfig } from '../api/immichApi.js'
import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
@@ -56,12 +57,16 @@ const PersonCard = ({
relationActionError = '',
siblingPlace = null,
onMoveSibling,
onUploadMomentPhoto,
}) => {
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
const [lifeState, setLifeState] = useState({ personId: null, data: null, loading: false, failed: false })
const lifeRequestedFor = useRef(null)
// Fiche « Ajouter un moment » : null fermée, {} pour ajouter, { event } pour modifier
const [momentSheet, setMomentSheet] = useState(null)
const [lifeRevision, setLifeRevision] = useState(0)
useEffect(() => {
if (tab !== 'vie' || lifeRequestedFor.current === person.id) return undefined
@@ -80,7 +85,7 @@ const PersonCard = ({
cancelled = true
lifeRequestedFor.current = null
}
}, [tab, person.id, treeId, immichToken])
}, [tab, person.id, treeId, immichToken, lifeRevision])
// État local pour les modifications en mode édition
const [edits, setEdits] = useState({})
@@ -112,6 +117,7 @@ const PersonCard = ({
setRelationSearchQuery('')
setSaveSuccess(false)
setShowSourceMenu(false)
setMomentSheet(null)
}, [person.id])
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
@@ -272,6 +278,8 @@ const PersonCard = ({
}
return [...seen.values()]
})()
// Ajouter un moment : administrateurs, sur un arbre en ligne (la démo reste en lecture)
const canEditLife = Boolean(isAdmin && onUploadMomentPhoto && treeId && immichToken)
const lifeFamily = {
parents: parentsList,
children: childrenList,
@@ -635,6 +643,10 @@ const PersonCard = ({
life={lifeState.personId === person.id ? lifeState.data : null}
loading={lifeState.personId !== person.id || lifeState.loading}
failed={lifeState.personId === person.id && lifeState.failed}
medias={personMedias}
canEdit={canEditLife}
onAdd={() => setMomentSheet({})}
onEdit={(event) => setMomentSheet({ event })}
/>
)}
@@ -858,6 +870,20 @@ const PersonCard = ({
onImmichChange={onImmichChange}
/>
)}
{/* ── Ajouter ou modifier un moment de l'onglet Vie (modale) ── */}
{momentSheet && canEditLife && (
<MomentSheet
person={person}
event={momentSheet.event || null}
treeId={treeId}
token={immichToken}
medias={personMedias}
onUploadPhoto={onUploadMomentPhoto}
onClose={() => setMomentSheet(null)}
onSaved={() => { setMomentSheet(null); setLifeRevision((r) => r + 1) }}
/>
)}
</div>
)
}
+29
View File
@@ -166,6 +166,34 @@ export function useMediaManager({
}
}
// Photo d'un moment de l'onglet Vie : envoyée comme un souvenir de la personne, puis rattachée au moment.
// Renvoie le souvenir créé ; une erreur remonte avec un message lisible.
const uploadMomentPhoto = async (personId, file, caption) => {
if (!canEditCurrentTree || !treeContext.treeId || !treeContext.accessToken) throw new Error(t('Action non autorisée.'))
const validationError = validateMediaFile(file, 'photo', {})
if (validationError) throw new Error(validationError)
try {
const created = await uploadPersonMedia(
treeContext.treeId,
personId,
{
type: 'photo',
fileName: file.name || 'photo.jpg',
mimeType: getUploadMimeType(file, 'photo'),
sizeBytes: file.size,
dataBase64: await fileToBase64(file),
caption: caption || null,
source: null,
},
treeContext.accessToken,
)
await refreshAfterMediaMutation(personId)
return created
} catch (error) {
throw new Error(getAccountErrorMessage(error), { cause: error })
}
}
const handleMovePersonMedia = async (mediaId, direction) => {
if (!canEditCurrentTree || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) {
return
@@ -250,6 +278,7 @@ export function useMediaManager({
handleCloseMediaManager,
handleUploadPersonMedia,
handleMovePersonMedia,
uploadMomentPhoto,
handleDeletePersonMedia,
resetMediaManager,
}
+25
View File
@@ -357,4 +357,29 @@ export default {
'Événement': 'Event',
'Naissance de {name}': 'Birth of {name}',
'Décès à {place}': 'Died in {place}',
'Ajouter un moment': 'Add a moment',
'Modifier « {title} »': 'Edit “{title}”',
'Déménagement': 'Move',
'L’enregistrement n’a pas abouti. Réessayez dans un instant.': 'Saving did not go through. Try again in a moment.',
'La suppression n’a pas abouti. Réessayez dans un instant.': 'Deleting did not go through. Try again in a moment.',
'Modifier ce moment': 'Edit this moment',
'Ajouter à la vie de {name}': 'Add to {name}’s life',
'Quoi ?': 'What?',
'Service militaire, premier emploi, départ pour Paris…': 'Military service, first job, moving to Paris…',
'Quand ?': 'When?',
'Année': 'Year',
'environ': 'about',
'Où ?': 'Where?',
'Ville ou adresse': 'Town or address',
'Retirer la photo': 'Remove the photo',
'Une photo': 'A photo',
'Plus de détails': 'More details',
'Date exacte': 'Exact date',
'Jusqu’en': 'Until',
'Registre, acte, archive, témoignage… une par ligne': 'Register, record, archive, account… one per line',
'Note': 'Note',
'Garder': 'Keep',
'Supprimer ce moment': 'Delete this moment',
'Enregistrement…': 'Saving…',
'Action non autorisée.': 'Not allowed.',
}
+14 -5
View File
@@ -45,6 +45,16 @@ const EVENT_LABELS = {
marriage_settlement: t('Accord de mariage'),
}
// Types dont la description dit déjà ce que c'est (« Instituteur », « École normale »)
export const DESCRIBED_TYPES = ['occupation', 'education', 'graduation', 'degree', 'title']
// Titre d'un événement : son libellé s'il en a un, sinon le nom du type, complété de la description
export function eventTitle(event) {
if (DESCRIBED_TYPES.includes(event.type) && event.description) return event.description
const base = event.label || (event.type === 'custom' ? '' : EVENT_LABELS[event.type]) || t('Événement')
return event.description ? `${base}, ${event.description}` : base
}
const yearOf = (value) => {
const match = String(value ?? '').match(/^(\d{4})/)
return match ? Number(match[1]) : null
@@ -147,11 +157,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
for (const event of events) {
if (['birth', 'death', 'marriage'].includes(event.type)) continue
const year = yearOf(event.date)
let title = event.type === 'custom' ? (event.label || t('Événement')) : (EVENT_LABELS[event.type] || event.label || t('Événement'))
// Métier, études, diplôme, titre : la description dit déjà ce que c'est (« Instituteur », « École normale »)
if (['occupation', 'education', 'graduation', 'degree', 'title'].includes(event.type) && event.description) title = event.description
else if (event.description && event.type !== 'custom') title = `${title}, ${event.description}`
else if (event.type === 'custom' && event.description) title = `${title}, ${event.description}`
let title = eventTitle(event)
if (event.unionId) {
const union = (family.unions || []).find((u) => String(u.id) === String(event.unionId))
if (union?.partner && ['divorce', 'annulment', 'engagement', 'marriage_contract'].includes(event.type)) title = `${title} (${union.partner.firstName})`
@@ -160,6 +166,9 @@ export function buildLifeTimeline(person, family = {}, life = null) {
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
meta: joinMeta(formatPlace(event.place || ''), describeDate(event.date, event.dateEnd, event.datePrecision), ageAt(birthYear, year)),
note: event.notes || '', sources: event.sources || '',
mediaId: event.mediaId || null,
// Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union)
event: event.unionId ? null : event,
})
}