mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-14 00:22:03 +00:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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' }],
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user