mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-14 16:30:50 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3d5d9fea21
22 files changed
+977
-83
No files matched your search
@@ -77,6 +77,7 @@ const personCreateSchema = z.object({
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
@@ -94,6 +95,7 @@ const personUpdateSchema = z
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
@@ -386,6 +388,7 @@ async function applyContributionChange(
|
||||
deathDate: toDateOrNull(parsed.data.deathDate),
|
||||
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
|
||||
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(parsed.data.profession),
|
||||
region: toTrimmedStringOrNull(parsed.data.region),
|
||||
nationality: toTrimmedStringOrNull(parsed.data.nationality),
|
||||
@@ -454,6 +457,7 @@ async function applyContributionChange(
|
||||
deathDate: nextDates.deathDate,
|
||||
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
|
||||
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
|
||||
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
|
||||
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
|
||||
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
|
||||
nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined,
|
||||
@@ -852,6 +856,7 @@ async function snapshotEntity(
|
||||
deathDate: true,
|
||||
isDeceased: true,
|
||||
birthPlace: true,
|
||||
deathPlace: true,
|
||||
profession: true,
|
||||
region: true,
|
||||
nationality: true,
|
||||
|
||||
@@ -97,6 +97,13 @@ const reorderMediaSchema = z.object({
|
||||
orderedMediaIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
const MAX_CAPTION_LENGTH = 280
|
||||
const MAX_CITATION_LENGTH = 2000
|
||||
const updateMediaSchema = z.object({
|
||||
caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH),
|
||||
lifeEventId: z.string().min(1).max(64).nullable().optional(),
|
||||
})
|
||||
|
||||
function hasAdminRight(role: MembershipRole): boolean {
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
@@ -822,6 +829,113 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte
|
||||
// du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques.
|
||||
app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const params = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
personId: z.string().min(1),
|
||||
mediaId: z.string().min(1),
|
||||
})
|
||||
.safeParse(request.params)
|
||||
|
||||
if (!params.success) {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
|
||||
if (!membership) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
const payload = updateMediaSchema.safeParse(request.body)
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
|
||||
const existingMedia = await app.prisma.mediaItem.findFirst({
|
||||
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
|
||||
select: { id: true, type: true, filePath: true },
|
||||
})
|
||||
if (!existingMedia) {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
const isCitation = existingMedia.type === 'citation'
|
||||
if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) {
|
||||
return reply.code(400).send({ error: 'caption_too_long' })
|
||||
}
|
||||
|
||||
const lifeEventId = payload.data.lifeEventId
|
||||
if (lifeEventId) {
|
||||
const event = await app.prisma.lifeEvent.findFirst({
|
||||
where: { id: lifeEventId, treeId: params.data.id, deletedAt: null },
|
||||
select: { personId: true, unionId: true, status: true },
|
||||
})
|
||||
let ownsEvent = event?.personId === params.data.personId
|
||||
if (event && !ownsEvent && event.unionId) {
|
||||
const union = await app.prisma.union.findFirst({
|
||||
where: { id: event.unionId, treeId: params.data.id },
|
||||
select: { partner1PersonId: true, partner2PersonId: true },
|
||||
})
|
||||
ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId
|
||||
}
|
||||
if (!event || !ownsEvent || event.status !== 'approved') {
|
||||
return reply.code(400).send({ error: 'invalid_life_event_reference' })
|
||||
}
|
||||
}
|
||||
|
||||
const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = {
|
||||
caption: payload.data.caption,
|
||||
}
|
||||
if (lifeEventId !== undefined) data.lifeEventId = lifeEventId
|
||||
|
||||
if (isCitation) {
|
||||
const buffer = Buffer.from(payload.data.caption, 'utf8')
|
||||
const storageRoot = getStorageRoot()
|
||||
const absolutePath = path.resolve(storageRoot, existingMedia.filePath)
|
||||
if (!absolutePath.startsWith(storageRoot + path.sep)) {
|
||||
return reply.code(400).send({ error: 'invalid_media_path' })
|
||||
}
|
||||
await writeFile(absolutePath, buffer)
|
||||
data.sizeBytes = buffer.length
|
||||
}
|
||||
|
||||
await app.prisma.mediaItem.updateMany({
|
||||
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: request.actor.userId,
|
||||
action: 'media_updated',
|
||||
entityType: 'media_item',
|
||||
entityId: params.data.mediaId,
|
||||
payloadJson: {
|
||||
personId: params.data.personId,
|
||||
lifeEventChanged: lifeEventId !== undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
ok: true,
|
||||
media: {
|
||||
id: params.data.mediaId,
|
||||
caption: payload.data.caption,
|
||||
lifeEventId,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
|
||||
@@ -93,6 +93,7 @@ const createPersonSchema = z.object({
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
@@ -110,6 +111,7 @@ const patchPersonSchema = z
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
@@ -1319,6 +1321,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
|
||||
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
|
||||
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(payload.data.profession),
|
||||
region: toTrimmedStringOrNull(payload.data.region),
|
||||
nationality: toTrimmedStringOrNull(payload.data.nationality),
|
||||
@@ -1388,6 +1391,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
deathDate?: Date | null
|
||||
isDeceased?: boolean
|
||||
birthPlace?: string | null
|
||||
deathPlace?: string | null
|
||||
profession?: string | null
|
||||
region?: string | null
|
||||
nationality?: string | null
|
||||
@@ -1432,6 +1436,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
|
||||
}
|
||||
|
||||
if (payload.data.deathPlace !== undefined) {
|
||||
data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace)
|
||||
}
|
||||
|
||||
if (payload.data.profession !== undefined) {
|
||||
data.profession = toTrimmedStringOrNull(payload.data.profession)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type GraphPayload = {
|
||||
deathDate: string | null
|
||||
isDeceased: boolean
|
||||
birthPlace: string | null
|
||||
deathPlace: string | null
|
||||
profession: string | null
|
||||
region: string | null
|
||||
nationality: string | null
|
||||
@@ -92,6 +93,7 @@ export function buildGraphPayload(
|
||||
deathDate: formatDate(person.deathDate),
|
||||
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
|
||||
birthPlace: person.birthPlace,
|
||||
deathPlace: person.deathPlace,
|
||||
profession: person.profession,
|
||||
region: person.region,
|
||||
nationality: person.nationality,
|
||||
|
||||
@@ -1008,3 +1008,29 @@ describe('Surnom (nickname) en écriture directe', () => {
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lieu de décès et filiation adoptive (écriture)', () => {
|
||||
it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
|
||||
|
||||
expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' })
|
||||
|
||||
expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' })
|
||||
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 })
|
||||
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
tree: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
count: vi.fn(async () => 0),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
treeSettings: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
treeAccessPasswords: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
treeSharePassword: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
userTreeAccess: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
person: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
union: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
parentChildLink: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
mediaItem: {
|
||||
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
lifeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contributionChange: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) }
|
||||
})
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
const URL_MEDIA = '/trees/tree-1/persons/person-1/media/media-1'
|
||||
|
||||
beforeAll(async () => {
|
||||
applyTestEnv()
|
||||
const module = await import('../src/app.js')
|
||||
createApp = module.createApp
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||||
treeId: 'tree-1',
|
||||
userId: 'user-1',
|
||||
role: 'owner',
|
||||
tree: { deletedAt: null, rootPersonId: null },
|
||||
})
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', type: 'photo', filePath: 'tree-1/person-1/media-1.jpg' })
|
||||
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, status: 'approved' })
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
||||
})
|
||||
|
||||
describe('PATCH media (modifier un souvenir)', () => {
|
||||
it('refuses a visitor', async () => {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'visitor', tree: { deletedAt: null } })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a contributor', async () => {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'contributor', tree: { deletedAt: null } })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts an admin and updates title and life event', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: URL_MEDIA,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ id: 'media-1', treeId: 'tree-1', personId: 'person-1' }),
|
||||
data: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
|
||||
}))
|
||||
expect(response.json().media).toEqual({ id: 'media-1', caption: 'Mariage à Caen', lifeEventId: 'event-1' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('clears the life event with null and keeps it when the key is absent', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: null } })
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo', lifeEventId: null })
|
||||
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects an empty title and a photo title above 280 characters', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const empty = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: ' ' } })
|
||||
expect(empty.statusCode).toBe(400)
|
||||
const long = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'a'.repeat(281) } })
|
||||
expect(long.statusCode).toBe(400)
|
||||
expect(long.json().error).toBe('caption_too_long')
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a life event from another tree or another person', async () => {
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const otherTree = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-other-tree' } })
|
||||
expect(otherTree.statusCode).toBe(400)
|
||||
expect(otherTree.json().error).toBe('invalid_life_event_reference')
|
||||
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: 'person-2', unionId: null, status: 'approved' })
|
||||
const otherPerson = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-2' } })
|
||||
expect(otherPerson.statusCode).toBe(400)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts a life event of a union the person belongs to', async () => {
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: null, unionId: 'union-1', status: 'approved' })
|
||||
prismaMock.union.findFirst.mockResolvedValueOnce({ partner1PersonId: 'person-1', partner2PersonId: 'person-9' })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-union' } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns 404 for a media that does not belong to the person', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
|
||||
expect(response.statusCode).toBe(404)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rewrites the citation file with the same text as caption', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const text = 'Il aimait la mer, « toujours »'
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: text } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
const writes = (writeFile as unknown as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(writes.length).toBe(1)
|
||||
expect(String(writes[0][0])).toMatch(/media-1\.txt$/)
|
||||
expect(Buffer.from(writes[0][1] as Buffer).toString('utf8')).toBe(text)
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({
|
||||
caption: text,
|
||||
sizeBytes: Buffer.byteLength(text, 'utf8'),
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts a citation longer than 280 characters', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'b'.repeat(500) } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
+78
-7
@@ -26,6 +26,7 @@ import { ExpandableSearch } from './components/search'
|
||||
import { t, dateLocale } from './i18n/index.js'
|
||||
import {
|
||||
createParentChildLink,
|
||||
updateParentChildLink,
|
||||
deleteParentChildLink,
|
||||
deletePerson,
|
||||
deleteUnion,
|
||||
@@ -258,9 +259,9 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
const relatedId = String(rel.personId)
|
||||
if (rel.type === 'child') {
|
||||
linkedParentIds.add(relatedId)
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } })
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
|
||||
} else if (rel.type === 'parent') {
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } })
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
|
||||
} else if (rel.type === 'spouse') {
|
||||
changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } })
|
||||
}
|
||||
@@ -277,6 +278,16 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Lien avec ses parents basculé (naissance / adoption) sur un lien existant
|
||||
const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {}
|
||||
for (const [childId, parentageType] of Object.entries(parentageChanges)) {
|
||||
for (const filiation of filiations) {
|
||||
if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue
|
||||
if ((filiation.parentageType || 'biologique') === parentageType) continue
|
||||
changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } })
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object'
|
||||
? draft.modifiedPersons
|
||||
: {}
|
||||
@@ -292,6 +303,7 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
|
||||
@@ -1041,7 +1053,7 @@ function App() {
|
||||
childId: rel.personId,
|
||||
parentId: personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
parentageType: rel.adopted ? 'adoption' : 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
})
|
||||
} else if (rel.type === 'child') {
|
||||
@@ -1050,7 +1062,7 @@ function App() {
|
||||
childId: personId,
|
||||
parentId: rel.personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
parentageType: rel.adopted ? 'adoption' : 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
})
|
||||
}
|
||||
@@ -1090,7 +1102,7 @@ function App() {
|
||||
birthDate: formData.birthDate || null,
|
||||
deathDate: formData.deathDate || null,
|
||||
isDeceased: Boolean(formData.isDeceased),
|
||||
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })),
|
||||
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
|
||||
siblingParentLinks,
|
||||
},
|
||||
]
|
||||
@@ -1128,13 +1140,13 @@ function App() {
|
||||
linkedParentIds.add(String(rel.personId))
|
||||
await createParentChildLink(
|
||||
tree.treeContext.treeId,
|
||||
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 },
|
||||
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
|
||||
auth.userAuth.token
|
||||
)
|
||||
} else if (rel.type === 'parent') {
|
||||
await createParentChildLink(
|
||||
tree.treeContext.treeId,
|
||||
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 },
|
||||
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
|
||||
auth.userAuth.token
|
||||
)
|
||||
} else if (rel.type === 'spouse') {
|
||||
@@ -1247,6 +1259,7 @@ function App() {
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
|
||||
@@ -1530,6 +1543,7 @@ function App() {
|
||||
localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null
|
||||
@@ -1559,6 +1573,7 @@ function App() {
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null
|
||||
@@ -1833,6 +1848,48 @@ function App() {
|
||||
}
|
||||
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi
|
||||
const [pendingParentage, setPendingParentage] = useState({})
|
||||
useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne
|
||||
const handleSetParentage = useCallback(async (childPersonId, parentageType) => {
|
||||
const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId))
|
||||
if (childLinks.length === 0) return
|
||||
|
||||
if (isDemoMode) {
|
||||
for (const link of childLinks) link.parentageType = parentageType
|
||||
tree.bumpGraphRevision()
|
||||
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!tree.canEditCurrentTree) {
|
||||
const draft = editMode.restoreDraft()
|
||||
const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType }
|
||||
editMode.updateDraft({ parentageChanges })
|
||||
setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType }))
|
||||
setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!accessToken || !tree.treeContext.treeId) return
|
||||
|
||||
setRelationFeedback({ loading: true, success: '', error: '' })
|
||||
try {
|
||||
for (const link of childLinks) {
|
||||
if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue
|
||||
await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken)
|
||||
}
|
||||
await refreshTreeAndKeepSelection(childPersonId, { relayout: true })
|
||||
setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' })
|
||||
} catch (err) {
|
||||
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
|
||||
console.error('Failed to update parentage:', err)
|
||||
}
|
||||
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Suppression d'une relation (mode édition)
|
||||
const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
|
||||
if (isDemoMode) {
|
||||
@@ -3065,6 +3122,8 @@ function App() {
|
||||
onDelete={handlePersonDelete}
|
||||
onAddRelation={handleAddRelation}
|
||||
onRemoveRelation={handleRemoveRelation}
|
||||
onSetParentage={handleSetParentage}
|
||||
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
|
||||
relationActionLoading={relationFeedback.loading}
|
||||
relationActionSuccess={relationFeedback.success}
|
||||
relationActionError={relationFeedback.error}
|
||||
@@ -3074,11 +3133,23 @@ function App() {
|
||||
onMomentSaved={media.refreshMomentMedia}
|
||||
/>
|
||||
)}
|
||||
{/* Crayon de modification masqué en démo : rien n'y est enregistré */}
|
||||
{tree.selectedMedia && (
|
||||
<MediaViewer
|
||||
media={tree.selectedMedia.media}
|
||||
person={tree.selectedMedia.person}
|
||||
onClose={() => tree.setSelectedMedia(null)}
|
||||
canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)}
|
||||
onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)}
|
||||
onSave={async (payload) => {
|
||||
const { person, media: current } = tree.selectedMedia
|
||||
await media.handleUpdatePersonMedia(person.id, current.id, payload)
|
||||
// Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié
|
||||
tree.setSelectedMedia({
|
||||
person,
|
||||
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MediaManagerPanel
|
||||
|
||||
@@ -235,6 +235,19 @@ export async function reorderSiblings(treeId, order, token) {
|
||||
})
|
||||
}
|
||||
|
||||
// Lien parent-enfant modifié (type de filiation : naissance ou adoption)
|
||||
export async function updateParentChildLink(treeId, linkId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
return data.link
|
||||
}
|
||||
|
||||
export async function deleteParentChildLink(treeId, linkId, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
|
||||
method: 'DELETE',
|
||||
@@ -441,6 +454,22 @@ export async function deletePersonMedia(treeId, personId, mediaId, token) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Modifier un souvenir (titre, moment de vie, texte de citation) : administrateurs seulement
|
||||
export async function updatePersonMedia(treeId, personId, mediaId, payload, token) {
|
||||
const data = await apiRequest(
|
||||
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function uploadPersonAvatar(treeId, personId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -42,6 +42,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
const [deathDate, setDeathDate] = useState('')
|
||||
// Décédé(e) sans date connue (issue #77)
|
||||
const [isDeceased, setIsDeceased] = useState(false)
|
||||
// Lien d'enfant ou de parent : la personne qui est l'enfant du lien a été adoptée
|
||||
const [adopted, setAdopted] = useState(false)
|
||||
// Les champs de date gardent leur texte : on les recrée après chaque ajout
|
||||
const [dateFieldsKey, setDateFieldsKey] = useState(0)
|
||||
const [relations, setRelations] = useState([])
|
||||
@@ -157,7 +159,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
birthDate: birthDate || null,
|
||||
deathDate: deathDate || null,
|
||||
isDeceased: !deathDate && isDeceased,
|
||||
relations,
|
||||
// Adopté(e) : s'applique aux liens d'enfant ou de parent de cette fiche
|
||||
relations: relations.map((rel) => ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -341,6 +344,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedRelationType === 'child' || selectedRelationType === 'parent') && (
|
||||
<label className="apn-deceased">
|
||||
<input type="checkbox" checked={adopted} onChange={(e) => setAdopted(e.target.checked)} />
|
||||
{t('Adopté(e)')}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{relations.length > 0 && (
|
||||
<div className="apn-links">
|
||||
{relations.map((rel) => (
|
||||
|
||||
@@ -76,6 +76,7 @@ const PERSON_FIELD_LABELS = {
|
||||
nickname: t('Surnom'),
|
||||
birthDate: t('Date de naissance'),
|
||||
deathDate: t('Date de décès'),
|
||||
deathPlace: t('Lieu de décès'),
|
||||
birthPlace: t('Lieu de naissance'),
|
||||
profession: t('Métier'),
|
||||
nationality: t('Nationalité'),
|
||||
|
||||
@@ -450,3 +450,52 @@
|
||||
.mv-title { font-size: 22px; }
|
||||
.media-citation-text { font-size: 22px; }
|
||||
}
|
||||
|
||||
/* Modifier un souvenir : crayon discret, visible au survol sur ordinateur et au focus clavier,
|
||||
toujours visible sur écran tactile (pointeur grossier) */
|
||||
.mv-title-row { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }
|
||||
.mv-title-row .mv-title { min-width: 0; }
|
||||
.mv-edit-btn {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--pz-a100);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--pz-a700);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.mv-caption:hover .mv-edit-btn,
|
||||
.mv-caption:focus-within .mv-edit-btn,
|
||||
.mv-edit-btn:focus-visible { opacity: 1; }
|
||||
.mv-edit-btn:hover { background: var(--pz-a100); }
|
||||
.mv-edit-btn:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 2px; }
|
||||
@media (hover: none) {
|
||||
.mv-edit-btn { opacity: 1; }
|
||||
}
|
||||
|
||||
.mv-edit-form { display: grid; gap: 12px; }
|
||||
.mv-edit-field { display: grid; gap: 4px; }
|
||||
.mv-edit-label { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
|
||||
.mv-edit-title,
|
||||
.mv-edit-quote,
|
||||
.mv-edit-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--pz-a100);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--pz-ink);
|
||||
font: 400 16px/1.3 var(--pz-sans);
|
||||
}
|
||||
.mv-edit-title { font-family: var(--pz-serif); font-size: 22px; }
|
||||
.mv-edit-quote { font-family: var(--pz-serif); font-size: 18px; resize: vertical; }
|
||||
.mv-edit-error { margin: 0; font: 400 13px var(--pz-sans); color: var(--pz-a700); }
|
||||
.mv-edit-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Pencil, X } from 'lucide-react'
|
||||
import {
|
||||
buildOpenStreetMapEmbedUrl,
|
||||
buildOpenStreetMapUrl,
|
||||
@@ -10,11 +10,26 @@ import {
|
||||
import './MediaViewer.css'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { getAccountErrorMessage } from '../utils/errorMessages.js'
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\/\S+$/i
|
||||
const MAX_TITLE_LENGTH = 280
|
||||
const MAX_CITATION_LENGTH = 2000
|
||||
|
||||
const MediaViewer = ({ media, person, onClose }) => {
|
||||
// Moment de vie qui porte ce souvenir (mediaIds des événements de la route /life)
|
||||
const findLinkedEventId = (events, mediaId) => {
|
||||
const id = String(mediaId)
|
||||
return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null
|
||||
}
|
||||
|
||||
const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => {
|
||||
const [mapState, setMapState] = useState({ loading: false, error: '', data: null })
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [lifeEvents, setLifeEvents] = useState([])
|
||||
const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const svgUid = useId().replace(/:/g, '')
|
||||
const mapGridId = `media-map-grid-${svgUid}`
|
||||
const mapGlowId = `media-map-glow-${svgUid}`
|
||||
@@ -23,6 +38,109 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}
|
||||
|
||||
const isCitation = media?.type === 'citation'
|
||||
|
||||
const startEditing = async () => {
|
||||
setSaveError('')
|
||||
const linkedInitially = 'lifeEventId' in (media || {}) ? media.lifeEventId ?? null : null
|
||||
setDraft({ title: media?.label || '', citationText: media?.label || '', lifeEventId: linkedInitially })
|
||||
setEditing(true)
|
||||
if (!onLoadLifeEvents) return
|
||||
try {
|
||||
const data = await onLoadLifeEvents()
|
||||
const events = Array.isArray(data?.events) ? data.events : []
|
||||
setLifeEvents(events)
|
||||
if (!('lifeEventId' in (media || {}))) {
|
||||
setDraft((current) => ({ ...current, lifeEventId: findLinkedEventId(events, media.id) }))
|
||||
}
|
||||
} catch {
|
||||
setSaveError(t('Impossible de charger les moments de vie.'))
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false)
|
||||
setSaveError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
const caption = (isCitation ? draft.citationText : draft.title).trim()
|
||||
if (!caption) {
|
||||
setSaveError(isCitation ? t('Saisissez le texte de la citation.') : t('Donnez un titre au souvenir.'))
|
||||
return
|
||||
}
|
||||
if (caption.length > (isCitation ? MAX_CITATION_LENGTH : MAX_TITLE_LENGTH)) {
|
||||
setSaveError(isCitation ? t('Le texte de la citation est trop long.') : t('Le titre est trop long.'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
await onSave({ caption, lifeEventId: draft.lifeEventId ?? null })
|
||||
setEditing(false)
|
||||
} catch (error) {
|
||||
setSaveError(getAccountErrorMessage(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditForm = () => (
|
||||
<form className="mv-edit-form" onSubmit={handleSubmit}>
|
||||
{isCitation ? (
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Texte de la citation')}</span>
|
||||
<textarea
|
||||
className="mv-edit-quote"
|
||||
rows={3}
|
||||
maxLength={MAX_CITATION_LENGTH}
|
||||
value={draft.citationText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, citationText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Titre')}</span>
|
||||
<input
|
||||
className="mv-edit-title"
|
||||
type="text"
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Moment de sa vie')}</span>
|
||||
<select
|
||||
className="mv-edit-select"
|
||||
value={draft.lifeEventId ?? ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, lifeEventId: event.target.value || null }))}
|
||||
>
|
||||
<option value="">{t('Aucun')}</option>
|
||||
{lifeEvents.map((lifeEvent) => {
|
||||
const year = lifeEvent.date ? String(lifeEvent.date).slice(0, 4) : ''
|
||||
return (
|
||||
<option key={lifeEvent.id} value={lifeEvent.id}>
|
||||
{year ? `${eventTitle(lifeEvent)} (${year})` : eventTitle(lifeEvent)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
{saveError && <p className="mv-edit-error" role="alert">{saveError}</p>}
|
||||
<div className="mv-edit-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={cancelEditing} disabled={saving}>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={saving}>
|
||||
{t('Enregistrer')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
const birthYearLabel = person?.birthYear ?? '-'
|
||||
const personYears = person
|
||||
? person.deathYear
|
||||
@@ -387,8 +505,22 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="mv-caption">
|
||||
{mediaType !== 'citation' && media?.label && (
|
||||
<h2 className="mv-title">{media.label}</h2>
|
||||
{editing ? renderEditForm() : (
|
||||
<div className="mv-title-row">
|
||||
{mediaType !== 'citation' && media?.label && (
|
||||
<h2 className="mv-title">{media.label}</h2>
|
||||
)}
|
||||
{canEdit && onSave && (
|
||||
<button
|
||||
type="button"
|
||||
className="mv-edit-btn"
|
||||
onClick={startEditing}
|
||||
aria-label={t('Modifier le souvenir')}
|
||||
>
|
||||
<Pencil size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mv-meta">
|
||||
<span className="mv-person">
|
||||
@@ -402,7 +534,7 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
</span>
|
||||
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
|
||||
</div>
|
||||
{mediaSource && (
|
||||
{mediaSource && !editing && (
|
||||
<p className="mv-source">
|
||||
{t('Source')}{' '}
|
||||
{isMediaSourceUrl ? (
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
.pc-hidden-input { display: none; }
|
||||
|
||||
/* ── En-tête ── */
|
||||
.pc-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; align-items: start; padding-right: 36px; }
|
||||
.pc-head { --pc-name-h: 42px; --pc-name-gap: 6px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; align-items: start; padding-right: 36px; }
|
||||
.pc-photo-wrap { position: relative; display: grid; gap: 8px; justify-items: center; flex: none; }
|
||||
.pc-photo {
|
||||
position: relative; width: 84px; height: 84px; border-radius: 24%; overflow: hidden;
|
||||
@@ -59,8 +59,9 @@
|
||||
.pc-source-menu { position: fixed; z-index: 1150; min-width: 220px; }
|
||||
/* Mode modification : le portrait prend la hauteur des deux champs Prénom et Nom (écart compris) et reste carré */
|
||||
.person-card--edit-mode .pc-id { padding-top: 0; }
|
||||
.person-card--edit-mode .pc-photo-wrap { align-self: stretch; display: block; }
|
||||
.person-card--edit-mode .pc-photo-wrap > .pc-photo { width: auto; height: 100%; aspect-ratio: 1 / 1; }
|
||||
.person-card--edit-mode .pc-photo-wrap { display: block; }
|
||||
/* Taille fixée par les champs, pas par le contenu : une vraie photo a une taille propre qui faisait grandir le portrait */
|
||||
.person-card--edit-mode .pc-photo-wrap > .pc-photo { --pc-photo-edit: calc(var(--pc-name-h) * 2 + var(--pc-name-gap)); width: var(--pc-photo-edit); height: var(--pc-photo-edit); }
|
||||
|
||||
.pc-photo-badge {
|
||||
position: absolute; right: -6px; bottom: -6px; z-index: 1;
|
||||
@@ -101,7 +102,7 @@
|
||||
.pc-order-btn:hover:not(:disabled) { background: var(--pz-line-soft); color: var(--pz-ink); }
|
||||
}
|
||||
@media (pointer: coarse) { .pc-order-btn { width: 32px; height: 32px; } }
|
||||
.pc-name-edit { display: grid; gap: 6px; }
|
||||
.pc-name-edit { display: grid; gap: var(--pc-name-gap); }
|
||||
|
||||
/* ── Champs d'édition ── */
|
||||
.pc-input {
|
||||
@@ -112,7 +113,7 @@
|
||||
}
|
||||
.pc-input:focus { outline: none; border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.pc-input::placeholder { color: var(--pz-faint); }
|
||||
.pc-input--name { font: 400 20px/1.2 var(--pz-serif); min-height: 42px; }
|
||||
.pc-input--name { font: 400 20px/1.2 var(--pz-serif); min-height: var(--pc-name-h); }
|
||||
.pc-input--last { font-style: italic; }
|
||||
.pc-note-input { min-height: 88px; resize: vertical; }
|
||||
|
||||
@@ -269,3 +270,19 @@
|
||||
.pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; }
|
||||
.pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
|
||||
.pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); }
|
||||
|
||||
/* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */
|
||||
.pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.pc-fill-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px dashed var(--pz-a300); background: var(--pz-a50); color: var(--pz-a700); font: 500 13px var(--pz-sans); cursor: pointer; }
|
||||
@media (hover: hover) and (pointer: fine) { .pc-fill-chip:hover { background: var(--pz-a100); border-color: var(--pz-a500); } }
|
||||
.pc-fill-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
|
||||
/* Liens : parents adoptifs, réglage de la filiation */
|
||||
.pc-section-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; }
|
||||
.pc-adopt-tag { font: 500 12px var(--pz-sans); color: var(--pz-a700); background: var(--pz-a50); border: 1px solid var(--pz-a300); border-radius: 999px; padding: 1px 9px; }
|
||||
.pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); }
|
||||
.pc-seg { width: 100%; }
|
||||
.pc-seg .pz-tab { flex: 1; }
|
||||
|
||||
/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */
|
||||
.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PersonCard from './PersonCard.jsx'
|
||||
|
||||
// Onglet Infos : en modification, seuls les champs remplis s'affichent ; les vides sont des pastilles
|
||||
const sparse = {
|
||||
id: 'p-sparse',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Martin',
|
||||
nickname: 'Mamie Mimi',
|
||||
isAlive: false,
|
||||
birthDate: null,
|
||||
deathDate: null,
|
||||
}
|
||||
|
||||
const render = (person, editMode) => renderToStaticMarkup(
|
||||
<PersonCard person={person} editMode={editMode} onSave={() => {}} />,
|
||||
)
|
||||
|
||||
describe('PersonCard, onglet Infos', () => {
|
||||
it('en modification, une pastille par champ vide et le champ rempli reste affiché', () => {
|
||||
const html = render(sparse, true)
|
||||
expect(html).toContain('+ Date de naissance')
|
||||
expect(html).toContain('+ Lieu de naissance')
|
||||
expect(html).toContain('+ Nom de naissance')
|
||||
expect(html).toContain('+ Date de décès')
|
||||
expect(html).toContain('+ Lieu de décès')
|
||||
expect(html).toContain('+ Métier')
|
||||
expect(html).toContain('+ Région')
|
||||
expect(html).toContain('+ Nationalité')
|
||||
expect(html).toContain('+ Notes')
|
||||
// Le surnom est rempli : il n'a pas de pastille, il a son champ
|
||||
expect(html).not.toContain('+ Surnom')
|
||||
expect(html).toContain('Mamie Mimi')
|
||||
})
|
||||
|
||||
it('une personne vivante n’a ni pastille de décès ni champ de décès', () => {
|
||||
const html = render({ ...sparse, isAlive: true, nickname: null }, true)
|
||||
expect(html).not.toContain('+ Date de décès')
|
||||
expect(html).not.toContain('+ Lieu de décès')
|
||||
expect(html).toContain('+ Surnom')
|
||||
})
|
||||
|
||||
it('en lecture, pas de pastille, et le lieu de décès s’affiche quand il existe', () => {
|
||||
const html = render({ ...sparse, deathPlace: 'Nantes, France', deathDate: '1970-03-02' }, false)
|
||||
expect(html).not.toContain('pc-fill-chip')
|
||||
expect(html).toContain('Lieu de décès')
|
||||
expect(html).toContain('Nantes')
|
||||
})
|
||||
|
||||
it('en lecture, le lieu de décès absent n’a pas de ligne', () => {
|
||||
const html = render(sparse, false)
|
||||
expect(html).not.toContain('Lieu de décès')
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,9 @@ import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/pers
|
||||
import { isYearOnly } from '../utils/dateInput.js'
|
||||
import DateField from './DateField.jsx'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
|
||||
const TYPE_LABELS = {
|
||||
photo: t('Photo'),
|
||||
video: t('Vidéo'),
|
||||
@@ -58,6 +61,8 @@ const PersonCard = ({
|
||||
onDelete,
|
||||
onAddRelation,
|
||||
onRemoveRelation,
|
||||
onSetParentage,
|
||||
pendingParentage = null,
|
||||
relationActionLoading = false,
|
||||
relationActionSuccess = '',
|
||||
relationActionError = '',
|
||||
@@ -96,13 +101,17 @@ const PersonCard = ({
|
||||
}
|
||||
}, [tab, person.id, treeId, immichToken, lifeRevision])
|
||||
|
||||
// État local pour les modifications en mode édition
|
||||
const [edits, setEdits] = useState({})
|
||||
// Modifications en cours, rattachées à la personne qu'elles concernent. Au changement de fiche,
|
||||
// le premier rendu ne doit pas voir celles de la personne d'avant : un champ qui s'initialise à
|
||||
// ce moment-là (DateField) les garderait, et la date d'une personne passait sur une autre.
|
||||
const [editState, setEditState] = useState(() => ({ personId: person.id, values: {}, changed: false }))
|
||||
const ownEdits = editState.personId === person.id
|
||||
const edits = ownEdits ? editState.values : NO_EDITS
|
||||
const hasChanges = ownEdits && editState.changed
|
||||
// Prénom inconnu (case cochée) et saisie de prénom vide sans case : le prénom est demandé avant l'enregistrement
|
||||
const [unknownFirstName, setUnknownFirstName] = useState(() => isFirstNameUnknown(person))
|
||||
const [firstNameMissing, setFirstNameMissing] = useState(false)
|
||||
const [lastNameMissing, setLastNameMissing] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
@@ -125,14 +134,17 @@ const PersonCard = ({
|
||||
const [relationSearchQuery, setRelationSearchQuery] = useState('')
|
||||
const relationSearchRef = useRef(null)
|
||||
const avatarInputRef = useRef(null)
|
||||
// Infos en modification : champs ouverts depuis une pastille (et leur focus)
|
||||
const [openedFields, setOpenedFields] = useState([])
|
||||
const [focusField, setFocusField] = useState(null)
|
||||
const bodyRef = useRef(null)
|
||||
|
||||
// Reset edits quand la personne change
|
||||
useEffect(() => {
|
||||
setEdits({})
|
||||
setEditState({ personId: person.id, values: {}, changed: false })
|
||||
setUnknownFirstName(isFirstNameUnknown(person))
|
||||
setFirstNameMissing(false)
|
||||
setLastNameMissing(false)
|
||||
setHasChanges(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setActiveRelationSearch(null)
|
||||
setRelationSearchQuery('')
|
||||
@@ -143,8 +155,18 @@ const PersonCard = ({
|
||||
setPhotoMenuPos(null)
|
||||
setCropState(null)
|
||||
setPhotoSize(null)
|
||||
setOpenedFields([])
|
||||
setFocusField(null)
|
||||
}, [person.id])
|
||||
|
||||
// Pastille cliquée : le champ s'ouvre à sa place et prend le focus
|
||||
useEffect(() => {
|
||||
if (!focusField) return
|
||||
const target = bodyRef.current?.querySelector(`[data-pc-field="${focusField}"] input, [data-pc-field="${focusField}"] textarea`)
|
||||
target?.focus()
|
||||
setFocusField(null)
|
||||
}, [focusField])
|
||||
|
||||
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
|
||||
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
|
||||
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
|
||||
@@ -245,10 +267,50 @@ const PersonCard = ({
|
||||
}
|
||||
|
||||
const handleFieldChange = (field, value) => {
|
||||
setEdits(prev => ({ ...prev, [field]: value }))
|
||||
setHasChanges(true)
|
||||
const personId = person.id
|
||||
setEditState(prev => ({
|
||||
personId,
|
||||
values: { ...(prev.personId === personId ? prev.values : {}), [field]: value },
|
||||
changed: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// Onglet Infos en modification : seuls les champs remplis (ou ouverts) s'affichent, le reste est en pastilles
|
||||
const isFilled = (value) => value !== null && value !== undefined && String(value).trim() !== ''
|
||||
const openedHas = (field) => openedFields.includes(field)
|
||||
const openField = (field) => {
|
||||
setOpenedFields((prev) => (prev.includes(field) ? prev : [...prev, field]))
|
||||
setFocusField(field)
|
||||
}
|
||||
const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear))
|
||||
const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear))
|
||||
const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive
|
||||
const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note'))
|
||||
// En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli
|
||||
const infoRow = (field, label, filled, content, always = false) => {
|
||||
const shown = editMode ? (filled || openedHas(field)) : (always || filled)
|
||||
if (!shown) return null
|
||||
return (
|
||||
<div className="pc-row" data-pc-field={field}>
|
||||
<span className="pc-label">{label}</span>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chipCandidates = [
|
||||
{ field: 'nickname', label: t('Surnom'), filled: isFilled(getEditValue('nickname', person.nickname)), relevant: true },
|
||||
{ field: 'birthYear', label: t('Date de naissance'), filled: birthFilled, relevant: true },
|
||||
{ field: 'birthPlace', label: t('Lieu de naissance'), filled: isFilled(getEditValue('birthPlace', person.birthPlace)), relevant: true },
|
||||
{ field: 'deathYear', label: t('Date de décès'), filled: deathFilled, relevant: deceasedNow },
|
||||
{ field: 'deathPlace', label: t('Lieu de décès'), filled: isFilled(getEditValue('deathPlace', person.deathPlace)), relevant: deceasedNow },
|
||||
{ field: 'birthName', label: t('Nom de naissance'), filled: isFilled(getEditValue('birthName', person.birthName)), relevant: true },
|
||||
{ field: 'profession', label: t('Métier'), filled: isFilled(getEditValue('profession', person.profession)), relevant: true },
|
||||
{ field: 'region', label: t('Région'), filled: isFilled(getEditValue('region', person.region)), relevant: true },
|
||||
{ field: 'nationality', label: t('Nationalité'), filled: isFilled(getEditValue('nationality', person.nationality)), relevant: true },
|
||||
{ field: 'note', label: t('Notes'), filled: isFilled(getEditValue('note', person.note)), relevant: true },
|
||||
]
|
||||
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || !onSave) return
|
||||
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
|
||||
@@ -262,8 +324,10 @@ const PersonCard = ({
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(person.id, edits)
|
||||
setHasChanges(false)
|
||||
const savedFor = person.id
|
||||
await onSave(savedFor, edits)
|
||||
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
|
||||
setOpenedFields([])
|
||||
setSaveSuccess(true)
|
||||
setTimeout(() => setSaveSuccess(false), 2000)
|
||||
} finally {
|
||||
@@ -337,6 +401,18 @@ const PersonCard = ({
|
||||
return childFiliations[0]
|
||||
})()
|
||||
const personMedias = getPersonMedias(person.id)
|
||||
// Filiation d'adoption entre un enfant et un parent (lien direct ou via une union)
|
||||
const parentageAdopted = (childId, parentId) => filiations.some((f) => (
|
||||
String(f.childId) === String(childId)
|
||||
&& f.parentageType === 'adoption'
|
||||
&& (String(f.parentId) === String(parentId) || (f.unionId && personUnions.some((u) => String(u.id) === String(f.unionId))))
|
||||
))
|
||||
// Un contributeur voit son choix en attente avant l'envoi ; sinon le lien enregistré
|
||||
const parentsAdopted = pendingParentage ? pendingParentage === 'adoption' : filiations.some((f) => String(f.childId) === String(person.id) && f.parentageType === 'adoption')
|
||||
const adoptedChildLabel = (child) => {
|
||||
if (!parentageAdopted(child.id, person.id)) return null
|
||||
return child.sex === 'female' ? t('adoptée') : t('adopté')
|
||||
}
|
||||
|
||||
// Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life,
|
||||
// demandée seulement à l'ouverture de l'onglet (la démo a ses propres données)
|
||||
@@ -770,32 +846,14 @@ const PersonCard = ({
|
||||
|
||||
{/* ── Infos ── */}
|
||||
{tab === 'infos' && (
|
||||
<div className="pc-infos">
|
||||
<div className="pc-infos" ref={bodyRef}>
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('État civil')}</h3>
|
||||
<div className="pc-rows">
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Naissance')}</span>
|
||||
{renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' })}
|
||||
</div>
|
||||
{(person.birthPlace || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Lieu')}</span>
|
||||
{renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace })}
|
||||
</div>
|
||||
)}
|
||||
{(person.birthName || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Nom de naissance')}</span>
|
||||
{renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
|
||||
</div>
|
||||
)}
|
||||
{(person.nickname || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Surnom')}</span>
|
||||
{renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') })}
|
||||
</div>
|
||||
)}
|
||||
{infoRow('birthYear', t('Naissance'), birthFilled, renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' }), true)}
|
||||
{infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))}
|
||||
{infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))}
|
||||
{infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))}
|
||||
{editMode && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Statut')}</span>
|
||||
@@ -809,37 +867,28 @@ const PersonCard = ({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Décès')}</span>
|
||||
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') })}
|
||||
</div>
|
||||
)}
|
||||
{infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)}
|
||||
{infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))}
|
||||
{!editMode && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Âge')}</span>
|
||||
<span className="value">{ageLabel || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
{(person.region || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Région')}</span>
|
||||
{renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') })}
|
||||
</div>
|
||||
)}
|
||||
{(person.nationality || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Nationalité')}</span>
|
||||
{renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') })}
|
||||
</div>
|
||||
)}
|
||||
{(person.profession || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Métier')}</span>
|
||||
{renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') })}
|
||||
</div>
|
||||
)}
|
||||
{infoRow('region', t('Région'), isFilled(getEditValue('region', person.region)), renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') }))}
|
||||
{infoRow('nationality', t('Nationalité'), isFilled(getEditValue('nationality', person.nationality)), renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') }))}
|
||||
{infoRow('profession', t('Métier'), isFilled(getEditValue('profession', person.profession)), renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') }))}
|
||||
</div>
|
||||
{/* Modification : une pastille par champ vide, qui ouvre le champ à sa place */}
|
||||
{editMode && emptyChips.length > 0 && (
|
||||
<div className="pc-fill-chips">
|
||||
{emptyChips.map((chip) => (
|
||||
<button key={chip.field} type="button" className="pc-fill-chip" onClick={() => openField(chip.field)}>
|
||||
+ {chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!editMode && (parentsList.length > 0 || personUnions.length > 0 || childrenList.length > 0) && (
|
||||
@@ -881,8 +930,8 @@ const PersonCard = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(person.note || editMode) && (
|
||||
<section className="pc-section">
|
||||
{(editMode ? notesShown : person.note) && (
|
||||
<section className="pc-section" data-pc-field="note">
|
||||
<h3 className="pz-eyebrow">{t('Notes')}</h3>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
@@ -910,9 +959,21 @@ const PersonCard = ({
|
||||
)}
|
||||
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('Parents')}</h3>
|
||||
<div className="pc-section-head">
|
||||
<h3 className="pz-eyebrow">{t('Parents')}</h3>
|
||||
{parentsAdopted && parentsList.length > 0 && <span className="pc-adopt-tag">{t('Parents adoptifs')}</span>}
|
||||
</div>
|
||||
{parentsList.length > 0 ? parentsList.map(p => renderRelationRow(p, 'parent')) : <p className="pc-rel-empty">{t("Aucun parent pour l'instant")}</p>}
|
||||
{renderRelationAdd('parent', t('Ajouter un parent'), t('Rechercher un parent'))}
|
||||
{parentsList.length > 0 && (
|
||||
<div className="pc-parentage">
|
||||
<span className="pc-label">{t('Lien avec ses parents')}</span>
|
||||
<div className="pz-tabs pc-seg" role="radiogroup" aria-label={t('Lien avec ses parents')}>
|
||||
<button type="button" role="radio" aria-checked={!parentsAdopted} className={`pz-tab ${!parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'biologique')}>{t('Naissance')}</button>
|
||||
<button type="button" role="radio" aria-checked={parentsAdopted} className={`pz-tab ${parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'adoption')}>{t('Adoption')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="pc-section">
|
||||
@@ -927,7 +988,7 @@ const PersonCard = ({
|
||||
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('Enfants')}</h3>
|
||||
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child')) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
|
||||
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
|
||||
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -298,10 +298,8 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
|
||||
})
|
||||
|
||||
for (const { sourceId, sourcePos, pType, items, progress } of sibships.values()) {
|
||||
if (pType === 'adoption') {
|
||||
ctx.setLineDash([8, 5])
|
||||
ctx.strokeStyle = FIL.color
|
||||
} else if (pType === 'inconnu') {
|
||||
// Adoption : trait plein comme une filiation de naissance (le mode d'adoption se dit dans la fiche)
|
||||
if (pType === 'inconnu') {
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.strokeStyle = COLORS.linkUnknown
|
||||
} else {
|
||||
|
||||
@@ -115,6 +115,7 @@ function mapRemotePersonToLocal(person, current = null) {
|
||||
frameType: current?.frameType || pickFrameType(person?.id),
|
||||
note: person?.notes || null,
|
||||
birthPlace: person?.birthPlace || null,
|
||||
deathPlace: person?.deathPlace || null,
|
||||
region: person?.region || null,
|
||||
profession: person?.profession || null,
|
||||
nationality: person?.nationality || null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia } from '../api/treeApi'
|
||||
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia, updatePersonMedia, fetchPersonLife } from '../api/treeApi'
|
||||
import { getPersonMedias } from '../data/mockData'
|
||||
import { fileToBase64, getAccountErrorMessage } from '../utils/errorMessages'
|
||||
import { getUploadMimeType, resolveUploadMediaType, validateMediaFile } from '../utils/mediaUpload'
|
||||
@@ -273,6 +273,18 @@ export function useMediaManager({
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier un souvenir (visionneuse) : le serveur refuse tout autre rôle que l'administrateur
|
||||
const handleUpdatePersonMedia = async (personId, mediaId, payload) => {
|
||||
if (!canEditCurrentTree || !treeContext.treeId || !treeContext.accessToken) {
|
||||
throw new Error('forbidden')
|
||||
}
|
||||
|
||||
await updatePersonMedia(treeContext.treeId, personId, mediaId, payload, treeContext.accessToken)
|
||||
await refreshAfterMediaMutation(personId)
|
||||
}
|
||||
|
||||
const loadPersonLifeEvents = (personId) => fetchPersonLife(treeContext.treeId, personId, treeContext.accessToken)
|
||||
|
||||
const resetMediaManager = () => {
|
||||
setMediaManagerState({ visible: false, loading: false, error: '' })
|
||||
}
|
||||
@@ -287,6 +299,8 @@ export function useMediaManager({
|
||||
uploadMomentFile,
|
||||
refreshMomentMedia,
|
||||
handleDeletePersonMedia,
|
||||
handleUpdatePersonMedia,
|
||||
loadPersonLifeEvents,
|
||||
resetMediaManager,
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export default {
|
||||
'Lien familial ajouté (démo locale)': 'Family link added (local demo)',
|
||||
'Lien ajouté à la contribution': 'Link added to your contribution',
|
||||
'Lien familial ajouté': 'Family link added',
|
||||
'Lien modifié (démo locale)': 'Link changed (local demo)',
|
||||
'Lien modifié': 'Link changed',
|
||||
'Modification ajoutée à la contribution': 'Change added to the contribution',
|
||||
'Relation introuvable pour suppression': 'Could not find this relationship to delete',
|
||||
'Lien familial supprimé (démo locale)': 'Family link deleted (local demo)',
|
||||
'Suppression de lien ajoutée à la contribution': 'Link deletion added to your contribution',
|
||||
|
||||
@@ -39,6 +39,12 @@ export default {
|
||||
'Date de naissance': 'Date of birth',
|
||||
'Lieu': 'Place',
|
||||
'Lieu de naissance': 'Birthplace',
|
||||
'Lieu de décès': 'Place of death',
|
||||
'Parents adoptifs': 'Adoptive parents',
|
||||
'Lien avec ses parents': 'Link with parents',
|
||||
'adopté': 'adopted',
|
||||
'adoptée': 'adopted',
|
||||
'Adopté(e)': 'Adopted',
|
||||
'Nom de naissance': 'Birth name',
|
||||
'Surnom': 'Nickname',
|
||||
'Loulou': 'Lulu',
|
||||
|
||||
@@ -5,6 +5,7 @@ import fiche from './fiche.js'
|
||||
import contribution from './contribution.js'
|
||||
import compte from './compte.js'
|
||||
import photos from './photos.js'
|
||||
import souvenir from './souvenir.js'
|
||||
|
||||
export default {
|
||||
...app,
|
||||
@@ -13,5 +14,6 @@ export default {
|
||||
...contribution,
|
||||
...compte,
|
||||
...photos,
|
||||
...souvenir,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Lot « souvenir » : modifier un souvenir depuis la visionneuse (administrateurs, mode modification).
|
||||
export default {
|
||||
"Modifier le souvenir": "Edit memory",
|
||||
"Texte de la citation": "Quote text",
|
||||
"Moment de sa vie": "Moment in their life",
|
||||
"Aucun": "None",
|
||||
"Donnez un titre au souvenir.": "Give the memory a title.",
|
||||
"Le texte de la citation est trop long.": "The quote is too long.",
|
||||
"Le titre est trop long.": "The title is too long.",
|
||||
"Impossible de charger les moments de vie.": "Could not load the life moments.",
|
||||
}
|
||||
Reference in new issue
Block a user