Personnes identifiées sur un souvenir

Sur une photo ou une vidéo, on ajoute les personnes de l'arbre qui y figurent et, sur une photo,
on touche leur visage pour poser un point. Le souvenir apparaît aussi sur la fiche de chaque
personne identifiée (« Avec Odette »), un prénom ouvre sa fiche et recentre l'arbre.

L'administrateur enregistre directement. La famille propose, l'administrateur relit dans
« Propositions à relire ». Suppression d'une personne ou d'un souvenir : identifications retirées.

Table media_person_tags (migration additive), route PUT /trees/:id/media/:mediaId/people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-21 20:32:22 +02:00
1 parent bfda31f2b4
commit f65273e95a
33 files changed
+1270 -22

No files matched your search

@@ -0,0 +1,30 @@
-- Personnes identifiées sur un souvenir (feat/souvenirs-personnes). Migration additive : nouvelle table, nouvelle valeur d'énumération.
-- AlterEnum
ALTER TYPE "ContributionEntityType" ADD VALUE 'media_people';
-- CreateTable
CREATE TABLE "media_person_tags" (
"id" TEXT NOT NULL,
"treeId" TEXT NOT NULL,
"mediaId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"x" DOUBLE PRECISION,
"y" DOUBLE PRECISION,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "media_person_tags_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "media_person_tags_treeId_idx" ON "media_person_tags"("treeId");
-- CreateIndex
CREATE INDEX "media_person_tags_treeId_personId_idx" ON "media_person_tags"("treeId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "media_person_tags_mediaId_personId_key" ON "media_person_tags"("mediaId", "personId");
-- AddForeignKey
ALTER TABLE "media_person_tags" ADD CONSTRAINT "media_person_tags_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+23
View File
@@ -40,6 +40,8 @@ enum ContributionEntityType {
annotation
media
life_event
// Personnes identifiées sur un souvenir (remplace la liste entière)
media_people
}
// Souvenir envoyé par la famille : en attente jusqu'à la relecture du propriétaire
@@ -138,6 +140,7 @@ model Tree {
unions Union[]
parentChildLinks ParentChildLink[]
mediaItems MediaItem[]
mediaPersonTags MediaPersonTag[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
@@ -331,6 +334,26 @@ model MediaItem {
@@map("media_items")
}
// Personne identifiée sur un souvenir. Le souvenir reste à la personne qui le porte (media_items.personId) ;
// il apparaît aussi sur la fiche de chaque personne identifiée. x et y : point sur le visage, en proportion
// de l'image (0 à 1), facultatifs.
model MediaPersonTag {
id String @id @default(cuid())
treeId String
mediaId String
personId String
x Float?
y Float?
createdBy String?
createdAt DateTime @default(now())
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@unique([mediaId, personId])
@@index([treeId])
@@index([treeId, personId])
@@map("media_person_tags")
}
model ContributionSession {
id String @id @default(cuid())
treeId String
+9 -1
View File
@@ -47,7 +47,15 @@ export async function softDeletePersonCascade(
const medias = await db.mediaItem.findMany({
where: { treeId, personId, deletedAt: null },
select: { filePath: true, thumbPath: true },
select: { id: true, filePath: true, thumbPath: true },
})
// Identifications : la personne disparaît des photos des autres, et ses souvenirs n'ont plus personne
await db.mediaPersonTag.deleteMany({
where: {
treeId,
OR: [{ personId }, ...(medias.length > 0 ? [{ mediaId: { in: medias.map((media) => media.id) } }] : [])],
},
})
const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([
+73
View File
@@ -0,0 +1,73 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import { z } from 'zod'
type Db = PrismaClient | Prisma.TransactionClient
// Personnes identifiées sur un souvenir. La liste envoyée remplace la précédente : retirer quelqu'un, c'est
// renvoyer la liste sans lui. Le point (x, y) est facultatif, en proportion de l'image (0 à 1), et va par paire.
export const MAX_PEOPLE_PER_MEDIA = 50
const coordinate = z.number().min(0).max(1).nullable().optional()
export const mediaPersonSchema = z
.object({
personId: z.string().min(1).max(64),
x: coordinate,
y: coordinate,
})
.refine((value) => (value.x == null) === (value.y == null), { message: 'point_requires_x_and_y' })
export const mediaPeopleSchema = z
.array(mediaPersonSchema)
.max(MAX_PEOPLE_PER_MEDIA)
.refine((people) => new Set(people.map((person) => person.personId)).size === people.length, { message: 'duplicate_person' })
export type MediaPerson = { personId: string; x: number | null; y: number | null }
export function normalizeMediaPeople(people: z.infer<typeof mediaPeopleSchema>): MediaPerson[] {
return people.map((person) => ({ personId: person.personId, x: person.x ?? null, y: person.y ?? null }))
}
export async function readMediaPeople(db: Db, treeId: string, mediaId: string): Promise<MediaPerson[]> {
const tags = await db.mediaPersonTag.findMany({
where: { treeId, mediaId },
select: { personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
return tags
}
// Remplace les personnes d'un souvenir. Une personne supprimée entre l'envoi et la relecture est écartée
// sans faire échouer le reste. Renvoie la liste réellement enregistrée.
export async function replaceMediaPeople(
db: Db,
treeId: string,
mediaId: string,
people: MediaPerson[],
actorUserId: string | null,
): Promise<MediaPerson[]> {
const ids = people.map((person) => person.personId)
const existing = ids.length
? await db.person.findMany({ where: { treeId, id: { in: ids }, deletedAt: null }, select: { id: true } })
: []
const alive = new Set(existing.map((person) => person.id))
const kept = people.filter((person) => alive.has(person.personId))
await db.mediaPersonTag.deleteMany({ where: { treeId, mediaId } })
if (kept.length) {
// Une date par rang : l'ordre d'affichage suit celui de la liste envoyée
const base = Date.now()
await db.mediaPersonTag.createMany({
data: kept.map((person, index) => ({
treeId,
mediaId,
personId: person.personId,
x: person.x,
y: person.y,
createdBy: actorUserId,
createdAt: new Date(base + index),
})),
})
}
return kept
}
+134 -3
View File
@@ -8,6 +8,7 @@ import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
import { removeStoredFile } from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js'
import type { Actor, MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
@@ -155,9 +156,10 @@ const reorderSchema = z.object({
type ContributionStatus = 'pending' | 'approved' | 'rejected'
// Les souvenirs et les moments arrivent par leurs propres routes, jamais par une session envoyée en bloc
// Les souvenirs, les moments et les personnes d'un souvenir arrivent par leurs propres routes, jamais par une
// session envoyée en bloc
type ParsedChange = Omit<z.infer<typeof contributionChangeSchema>, 'entityType'> & {
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event'
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event' | 'media_people'
}
type TxClient = Prisma.TransactionClient
@@ -190,7 +192,7 @@ function recordTempRef(after: unknown, createdId: string, refs: RefMap) {
}
// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4, media_people: 5 }
function byApplicationOrder<T extends { entityType: string; action: string }>(changes: T[]): T[] {
const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1)
return changes.map((change, index) => ({ change, index }))
@@ -342,6 +344,26 @@ async function applyContributionChange(
return { entityId: change.entityId }
}
// Personnes identifiées sur un souvenir : la liste proposée remplace l'actuelle. Souvenir supprimé entre
// l'envoi et la relecture : rien à faire, la relecture continue.
if (change.entityType === 'media_people') {
if (change.action !== 'update' || !change.entityId) {
throw new ContributionRouteError(400, 'unsupported_change_action_for_media_people')
}
const people = mediaPeopleSchema.safeParse(isRecord(change.after) ? change.after.people : undefined)
if (!people.success) {
throw new ContributionRouteError(400, 'invalid_media_people')
}
const media = await tx.mediaItem.findFirst({
where: { id: change.entityId, treeId, status: 'approved', deletedAt: null },
select: { id: true },
})
if (media) {
await replaceMediaPeople(tx, treeId, change.entityId, normalizeMediaPeople(people.data), actorUserId)
}
return { entityId: change.entityId }
}
// Souvenir envoyé par la famille : le fichier existe déjà, on le rend visible
if (change.entityType === 'media') {
if (change.action !== 'create' || !change.entityId) {
@@ -1063,6 +1085,115 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
}
export const contributionRoutes: FastifyPluginAsync = async (app) => {
// Personnes identifiées sur un souvenir. Administrateur : enregistré tout de suite. Famille (membre, lien
// de partage) : une proposition, appliquée à la relecture, ou tout de suite si l'arbre l'autorise.
app.put('/trees/:id/media/:mediaId/people', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = z.object({ id: z.string().min(1), mediaId: z.string().min(1) }).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const payload = z.object({
people: mediaPeopleSchema,
submittedByLabel: z.string().trim().min(1).max(120).optional(),
}).safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload' })
}
const treeId = params.data.id
const mediaId = params.data.mediaId
try {
const context = await resolveSubmissionContext(app, request.actor, treeId)
const actor = request.actor
const membership = actor?.kind === 'user'
? await app.prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: actor.userId } }, select: { role: true } })
: null
const isAdmin = Boolean(membership && hasAdminRight(membership.role))
const media = await app.prisma.mediaItem.findFirst({
where: { id: mediaId, treeId, status: 'approved', deletedAt: null },
select: { id: true, personId: true },
})
if (!media) {
return reply.code(404).send({ error: 'media_not_found' })
}
const people = normalizeMediaPeople(payload.data.people)
const known = people.length
? await app.prisma.person.count({ where: { treeId, id: { in: people.map((person) => person.personId) }, deletedAt: null } })
: 0
if (known !== people.length) {
return reply.code(400).send({ error: 'invalid_person_reference' })
}
if (context.autoStatus === 'pending') {
const pendingCount = await app.prisma.contributionSession.count({ where: { treeId, status: 'pending' } })
if (pendingCount >= MAX_PENDING_SESSIONS_PER_TREE) {
return reply.code(429).send({ error: 'too_many_pending_contributions' })
}
}
const now = new Date()
const saved = await app.prisma.$transaction(async (tx) => {
const before = await tx.mediaPersonTag.findMany({
where: { treeId, mediaId },
select: { personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
const applied = context.autoStatus === 'approved'
? await replaceMediaPeople(tx, treeId, mediaId, people, context.submittedByUserId)
: null
// L'administrateur n'a rien à relire : pas de session, comme pour ses propres souvenirs
if (!isAdmin) {
const session = await tx.contributionSession.create({
data: {
treeId,
submittedByUserId: context.submittedByUserId,
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
mode: context.mode,
status: context.autoStatus,
title: 'Personnes sur un souvenir',
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
},
})
await tx.contributionChange.create({
data: {
sessionId: session.id,
entityType: 'media_people',
entityId: mediaId,
action: 'update',
beforeJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people: before }),
afterJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people }),
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
})
}
await tx.auditLog.create({
data: {
treeId,
actorType: context.actorType,
actorId: context.actorId,
action: 'media_people_updated',
entityType: 'media_item',
entityId: mediaId,
payloadJson: { status: context.autoStatus, count: people.length },
},
})
return applied
})
return reply.send({ status: context.autoStatus, people: saved ?? people })
} catch (error) {
if (error instanceof ContributionRouteError) {
return reply.code(error.statusCode).send({ error: error.code })
}
throw error
}
})
app.post('/trees/:id/contributions/sessions', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
+7 -2
View File
@@ -119,7 +119,7 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
// Journal des visites (onglet Visites des administrateurs), sans attendre
void recordTreeVisit(app, request, treeId, visitKind, actor.kind === 'user' ? actor.userId : null)
const [persons, unions, links, medias, annotations] = await Promise.all([
const [persons, unions, links, medias, annotations, mediaPeople] = await Promise.all([
app.prisma.person.findMany({
where: {
treeId,
@@ -156,9 +156,14 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
},
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
}),
app.prisma.mediaPersonTag.findMany({
where: { treeId },
select: { mediaId: true, personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
}),
])
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations)
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations, mediaPeople)
return reply.send({
treeId,
+2 -1
View File
@@ -982,8 +982,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'media_not_found' })
}
// Supprimé = effacé du disque (la ligne reste pour le journal)
// Supprimé = effacé du disque (la ligne reste pour le journal), et plus personne n'y est identifié
await removeStoredFile(getStorageRoot(), existingMedia?.filePath)
await app.prisma.mediaPersonTag.deleteMany({ where: { treeId: params.data.id, mediaId: params.data.mediaId } })
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
+14 -1
View File
@@ -1,4 +1,4 @@
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '@prisma/client'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
@@ -53,6 +53,8 @@ export type GraphPayload = {
source: string | null
displayOrder: number
isFeatured: boolean
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
people: Array<{ personId: string; x: number | null; y: number | null }>
}>
annotations: Array<{
id: string
@@ -81,7 +83,17 @@ export function buildGraphPayload(
links: ParentChildLink[],
medias: MediaItem[] = [],
annotations: Annotation[] = [],
mediaPeople: Pick<MediaPersonTag, 'mediaId' | 'personId' | 'x' | 'y'>[] = [],
): GraphPayload {
// Une personne supprimée n'est plus montrée sur les photos où elle était identifiée
const livingIds = new Set(persons.map((person) => person.id))
const peopleByMedia = new Map<string, Array<{ personId: string; x: number | null; y: number | null }>>()
for (const tag of mediaPeople) {
if (!livingIds.has(tag.personId)) continue
const list = peopleByMedia.get(tag.mediaId) ?? []
list.push({ personId: tag.personId, x: tag.x, y: tag.y })
peopleByMedia.set(tag.mediaId, list)
}
// Keep DB order from route queries to preserve sibling and union ordering semantics.
return {
persons: persons.map((person) => ({
@@ -131,6 +143,7 @@ export function buildGraphPayload(
source: media.source,
displayOrder: media.displayOrder,
isFeatured: media.isFeatured,
people: peopleByMedia.get(media.id) ?? [],
})),
annotations: annotations.map((ann) => ({
id: ann.id,
+5
View File
@@ -95,6 +95,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
@@ -91,6 +91,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
+5
View File
@@ -19,6 +19,11 @@ const { prismaMock } = vi.hoisted(() => ({
treeMembership: {
findUnique: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
},
+5
View File
@@ -96,6 +96,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
+30
View File
@@ -169,3 +169,33 @@ describe('buildGraphPayload', () => {
expect(graph.filiations[1].id).toBe('l-1')
})
})
describe('buildGraphPayload, personnes identifiées sur un souvenir', () => {
const person = (id: string) => ({ id, treeId: 't-1', firstName: id, lastName: 'X', birthDate: null, deathDate: null, avatarPath: null, deletedAt: null })
const media = { id: 'm-1', treeId: 't-1', personId: 'p-1', type: 'photo', mimeType: 'image/jpeg', filePath: 'f', caption: null, source: null, displayOrder: 1, isFeatured: false }
it('attaches people in order with their point, and drops a person missing from the tree', () => {
const graph = buildGraphPayload(
't-1',
[person('p-1'), person('p-2')] as any,
[],
[],
[media] as any,
[],
[
{ mediaId: 'm-1', personId: 'p-2', x: 0.3, y: 0.6 },
{ mediaId: 'm-1', personId: 'p-1', x: null, y: null },
{ mediaId: 'm-1', personId: 'p-gone', x: 0.5, y: 0.5 },
],
)
expect(graph.medias[0].people).toEqual([
{ personId: 'p-2', x: 0.3, y: 0.6 },
{ personId: 'p-1', x: null, y: null },
])
})
it('gives an empty list when nobody is identified', () => {
const graph = buildGraphPayload('t-1', [person('p-1')] as any, [], [], [media] as any)
expect(graph.medias[0].people).toEqual([])
})
})
+5
View File
@@ -25,6 +25,11 @@ const { prismaMock } = vi.hoisted(() => ({
mediaItem: {
aggregate: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
},
+5
View File
@@ -23,6 +23,11 @@ const { prismaMock } = vi.hoisted(() => ({
treeImmichConfig: {
findUnique: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
},
+6
View File
@@ -26,6 +26,11 @@ const { prismaMock } = vi.hoisted(() => ({
mediaItem: {
aggregate: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
},
@@ -1201,6 +1206,7 @@ describe('Immich integration: suppression logique d’une personne', () => {
calls.push({ model: name, method: 'updateMany', args })
return { count: 0 }
},
deleteMany: async () => ({ count: 0 }),
})
const db = new Proxy({} as Record<string, unknown>, {
get: (_target, name: string) => model(name),
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { replaceMediaPeople } from '../src/lib/media-people.js'
describe('replaceMediaPeople', () => {
it('keeps only people still in the tree, in the order sent', async () => {
const db = {
person: { findMany: vi.fn(async () => [{ id: 'p-3' }, { id: 'p-1' }]) },
mediaPersonTag: { deleteMany: vi.fn(async () => ({ count: 0 })), createMany: vi.fn(async () => ({ count: 2 })) },
}
const kept = await replaceMediaPeople(db as any, 't-1', 'm-1', [
{ personId: 'p-1', x: 0.1, y: 0.2 },
{ personId: 'p-deleted', x: null, y: null },
{ personId: 'p-3', x: null, y: null },
], 'u-1')
expect(kept.map((p) => p.personId)).toEqual(['p-1', 'p-3'])
expect(db.mediaPersonTag.deleteMany).toHaveBeenCalledWith({ where: { treeId: 't-1', mediaId: 'm-1' } })
const rows = (db.mediaPersonTag.createMany.mock.calls[0] as any)[0].data
expect(rows.map((row: any) => row.personId)).toEqual(['p-1', 'p-3'])
expect(rows[0].createdAt.getTime()).toBeLessThan(rows[1].createdAt.getTime())
})
it('only clears when the list is empty', async () => {
const db = {
person: { findMany: vi.fn() },
mediaPersonTag: { deleteMany: vi.fn(async () => ({ count: 3 })), createMany: vi.fn() },
}
expect(await replaceMediaPeople(db as any, 't-1', 'm-1', [], null)).toEqual([])
expect(db.person.findMany).not.toHaveBeenCalled()
expect(db.mediaPersonTag.createMany).not.toHaveBeenCalled()
})
})
+259
View File
@@ -0,0 +1,259 @@
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(),
},
mediaPersonTag: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: 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_PEOPLE = '/trees/tree-1/media/media-1/people'
beforeAll(async () => {
applyTestEnv()
const module = await import('../src/app.js')
createApp = module.createApp
})
function setRole(role: string) {
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role,
contributionModeOverride: null,
tree: { deletedAt: null, rootPersonId: null, settings: { memberContributionPolicy: 'pending' } },
})
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
setRole('owner')
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', personId: 'person-1' })
prismaMock.person.count.mockImplementation(async ({ where }: any) => where.id.in.length)
prismaMock.person.findMany.mockImplementation(async ({ where }: any) => where.id.in.map((id: string) => ({ id })))
prismaMock.mediaPersonTag.findMany.mockResolvedValue([{ personId: 'person-2', x: null, y: null }])
prismaMock.mediaPersonTag.deleteMany.mockResolvedValue({ count: 1 })
prismaMock.mediaPersonTag.createMany.mockResolvedValue({ count: 2 })
prismaMock.contributionSession.count.mockResolvedValue(0)
prismaMock.contributionSession.create.mockResolvedValue({ id: 'session-1' })
prismaMock.contributionChange.create.mockResolvedValue({ id: 'change-1' })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock))
})
const PEOPLE = [
{ personId: 'person-2', x: 0.25, y: 0.4 },
{ personId: 'person-3' },
]
async function put(app: FastifyInstance, payload: unknown) {
const token = await createUserToken(app)
return app.inject({ method: 'PUT', url: URL_PEOPLE, headers: { authorization: `Bearer ${token}` }, payload })
}
describe('PUT media people (identifier des personnes sur un souvenir)', () => {
it('saves directly for an admin, without a contribution session', async () => {
const app = createApp()
const response = await put(app, { people: PEOPLE })
expect(response.statusCode).toBe(200)
expect(response.json().status).toBe('approved')
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', mediaId: 'media-1' } })
const created = prismaMock.mediaPersonTag.createMany.mock.calls[0][0].data
expect(created.map((tag: any) => [tag.personId, tag.x, tag.y])).toEqual([['person-2', 0.25, 0.4], ['person-3', null, null]])
expect(prismaMock.contributionSession.create).not.toHaveBeenCalled()
await app.close()
})
it('turns a contributor request into a pending proposal, nothing applied', async () => {
setRole('contributor')
const app = createApp()
const response = await put(app, { people: PEOPLE, submittedByLabel: 'Lucie' })
expect(response.statusCode).toBe(200)
expect(response.json().status).toBe('pending')
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
expect(prismaMock.mediaPersonTag.deleteMany).not.toHaveBeenCalled()
expect(prismaMock.contributionSession.create.mock.calls[0][0].data).toMatchObject({ status: 'pending', submittedByLabel: 'Lucie' })
const change = prismaMock.contributionChange.create.mock.calls[0][0].data
expect(change).toMatchObject({ entityType: 'media_people', entityId: 'media-1', action: 'update' })
expect(change.beforeJson.people).toEqual([{ personId: 'person-2', x: null, y: null }])
expect(change.afterJson.people).toHaveLength(2)
await app.close()
})
it('refuses a person from another tree', async () => {
prismaMock.person.count.mockResolvedValueOnce(1)
const app = createApp()
const response = await put(app, { people: PEOPLE })
expect(response.statusCode).toBe(400)
expect(response.json().error).toBe('invalid_person_reference')
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
await app.close()
})
it('answers 404 for a missing or pending souvenir', async () => {
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
const app = createApp()
const response = await put(app, { people: PEOPLE })
expect(response.statusCode).toBe(404)
await app.close()
})
it('rejects a duplicate person, a lone coordinate and a point outside the photo', async () => {
const app = createApp()
const duplicate = await put(app, { people: [{ personId: 'person-2' }, { personId: 'person-2' }] })
expect(duplicate.statusCode).toBe(400)
const lone = await put(app, { people: [{ personId: 'person-2', x: 0.5 }] })
expect(lone.statusCode).toBe(400)
const outside = await put(app, { people: [{ personId: 'person-2', x: 1.2, y: 0.5 }] })
expect(outside.statusCode).toBe(400)
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
await app.close()
})
it('clears every identification with an empty list', async () => {
const app = createApp()
const response = await put(app, { people: [] })
expect(response.statusCode).toBe(200)
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalled()
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
await app.close()
})
it('requires a session', async () => {
const app = createApp()
await app.ready()
const response = await app.inject({ method: 'PUT', url: URL_PEOPLE, payload: { people: [] } })
expect(response.statusCode).toBe(401)
await app.close()
})
})
+5
View File
@@ -92,6 +92,11 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
},
mediaPersonTag: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
+17
View File
@@ -3309,6 +3309,23 @@ function App() {
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
})
}}
getPerson={(id) => persons.find((p) => p.id === id) || null}
allPersons={persons}
canTag={Boolean((tree.canEditCurrentTree || tree.canSubmitContribution) && !isDemoMode)}
onSavePeople={async (people) => {
const { person, media: current } = tree.selectedMedia
const result = await media.handleUpdateMediaPeople(person.id, current.id, people)
// Appliqué tout de suite (administrateur) : la visionneuse rouvre le souvenir avec ses personnes
if (result?.status === 'approved') tree.setSelectedMedia({ person, media: { ...current, people: result.people ?? people } })
return result
}}
onOpenPerson={async (personId) => {
const target = persons.find((p) => p.id === personId)
if (!target) return
tree.setSelectedMedia(null)
if (!(await chooseSelectedPerson(target))) return
galaxyZoomApiRef.current?.focusPerson?.(target.id)
}}
/>
)}
<MediaManagerPanel
+15
View File
@@ -470,6 +470,21 @@ export async function updatePersonMedia(treeId, personId, mediaId, payload, toke
return data
}
// Personnes identifiées sur un souvenir : la liste remplace la précédente. Administrateur, enregistré tout de
// suite ; famille, une proposition (status « pending ») relue par l'administrateur
export async function updateMediaPeople(treeId, mediaId, payload, token) {
return apiRequest(
`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(mediaId)}/people`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
},
)
}
export async function uploadPersonAvatar(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'POST',
+35 -2
View File
@@ -26,6 +26,7 @@ function formatEntityType(entityType) {
if (entityType === 'annotation') return t('annotation')
if (entityType === 'media') return t('souvenir')
if (entityType === 'life_event') return t('moment')
if (entityType === 'media_people') return t('personnes sur un souvenir')
return t('élément')
}
@@ -51,6 +52,17 @@ function getMediaPreview(change, treeId) {
return { type: data.type, label, caption, url, isYoutube }
}
// Personnes identifiées sur un souvenir : la photo, qui y était, qui est proposé
function getMediaPeoplePreview(change, treeId) {
const after = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : {}
const names = (list) => (Array.isArray(list) ? list : []).map((tag) => nameOfPersonRef(tag?.personId)).filter(Boolean)
const url = change.entityId && treeId
? buildMediaUrl(`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(change.entityId)}`, API_BASE_URL)
: null
return { url, before: names(before.people), after: names(after.people) }
}
// Moment proposé par la famille : quand, où, et ce qu'elle en dit. Ses fichiers sont dans « Souvenirs »
function getMomentPreview(change) {
const data = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
@@ -138,6 +150,10 @@ function getPersonDisplayName(change, sessionChanges = []) {
const data = change.afterJson || {}
return data.title || t('Moment')
}
if (change.entityType === 'media_people') {
const owner = nameOfPersonRef(change.afterJson?.ownerPersonId)
return owner ? t('Photo de {name}', { name: owner }) : t('Personnes sur un souvenir')
}
// Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base)
const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null
@@ -510,8 +526,8 @@ const ContributionPanel = ({
{/* ── Cartes de changement (collapsibles) ── */}
{!isCollapsed && (() => {
// Grouper les changements par entityType
const groupOrder = ['life_event', 'media', 'person', 'union', 'parent_child_link', 'annotation']
const groupLabels = { life_event: t('Moments'), media: t('Souvenirs'), person: t('Personnes'), union: t('Unions'), parent_child_link: t('Liens'), annotation: t('Annotations') }
const groupOrder = ['life_event', 'media', 'media_people', 'person', 'union', 'parent_child_link', 'annotation']
const groupLabels = { life_event: t('Moments'), media: t('Souvenirs'), media_people: t('Sur les photos'), person: t('Personnes'), union: t('Unions'), parent_child_link: t('Liens'), annotation: t('Annotations') }
const groupMap = {}
for (const ch of (session.changes || [])) {
const key = ch.entityType || 'other'
@@ -533,6 +549,7 @@ const ContributionPanel = ({
const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change, treeId) : null
const mediaPreview = change.entityType === 'media' ? getMediaPreview(change, treeId) : null
const momentPreview = change.entityType === 'life_event' ? getMomentPreview(change) : null
const peoplePreview = change.entityType === 'media_people' ? getMediaPeoplePreview(change, treeId) : null
return (
<div
@@ -603,6 +620,22 @@ const ContributionPanel = ({
</div>
)}
{peoplePreview && (
<div className="contrib-media-preview">
{peoplePreview.url && (
<a href={peoplePreview.url} target="_blank" rel="noreferrer" className="contrib-media-photo">
<img src={peoplePreview.url} alt={t('Photo du souvenir')} onError={(e) => { e.currentTarget.style.display = 'none' }} />
</a>
)}
<p className="contrib-media-caption">
{peoplePreview.after.length ? t('Sur la photo, {names}', { names: peoplePreview.after.join(', ') }) : t('Plus personne sur la photo')}
</p>
{peoplePreview.before.length > 0 && (
<p className="contrib-media-caption">{t('Avant, {names}', { names: peoplePreview.before.join(', ') })}</p>
)}
</div>
)}
{momentPreview && (
<div className="contrib-moment-preview">
{momentPreview.when && <span>{momentPreview.when}</span>}
+123
View File
@@ -0,0 +1,123 @@
/* Personnes identifiées sur un souvenir : points sur les visages, liste « Sur la photo », identification */
.mp-avatar { width: 24px; height: 24px; flex: none; border-radius: 50%; object-fit: cover; }
.mp-avatar--initial { display: grid; place-items: center; background: var(--pz-a100); color: var(--pz-a700); font: 400 12px/1 var(--pz-serif); }
/* Point sur un visage : rond blanc cerclé, le prénom apparaît au survol ou au focus (toujours en identification) */
.mp-marker {
position: absolute;
width: 26px;
height: 26px;
margin: -13px 0 0 -13px;
padding: 0;
border: 2px solid #fff;
border-radius: 50%;
background: transparent;
box-shadow: 0 0 0 1px rgba(42, 38, 34, .3), 0 2px 8px rgba(42, 38, 34, .25);
cursor: pointer;
}
.mp-marker.is-active { border-color: var(--pz-a500); box-shadow: 0 0 0 3px rgba(210, 105, 74, .35), 0 2px 8px rgba(42, 38, 34, .25); }
.mp-marker:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 3px; }
.mp-marker-name {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 9px;
border-radius: 999px;
background: var(--pz-media-badge-bg);
color: #fff;
font: 500 12.5px/1.3 var(--pz-sans);
white-space: nowrap;
pointer-events: none;
visibility: hidden;
}
.mp-marker:hover .mp-marker-name,
.mp-marker:focus-visible .mp-marker-name,
.mv-photo-box.is-tagging .mp-marker-name,
.mv-photo-box.show-names .mp-marker-name { visibility: visible; }
@media (hover: none) { .mp-marker-name { visibility: visible; } }
/* Photo cliquable pendant le placement d'un point */
.mv-photo-box.is-placing img { cursor: crosshair; }
/* Liste « Sur la photo » */
.mp-list { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.mp-list-label { font: 500 13px/1.3 var(--pz-sans); color: var(--pz-muted); margin-right: 2px; }
.mp-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 3px 12px 3px 4px;
border: 1px solid var(--pz-line);
border-radius: 999px;
background: var(--pz-surface);
color: var(--pz-ink);
font: 500 13.5px/1.2 var(--pz-sans);
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) { .mp-chip:hover { border-color: var(--pz-a300); background: var(--pz-a50); } }
.mp-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
/* Identification */
.mp-edit { display: grid; gap: 8px; }
.mp-edit-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.mp-edit-row { display: flex; align-items: center; gap: 4px; border-radius: var(--pz-r-s); }
.mp-edit-row.is-active { background: var(--pz-a50); }
.mp-edit-pick {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 4px 8px;
border: 0;
border-radius: var(--pz-r-s);
background: transparent;
color: var(--pz-ink);
font: 400 14.5px/1.3 var(--pz-sans);
text-align: left;
cursor: pointer;
}
.mp-edit-pick:disabled { cursor: default; }
.mp-edit-pick:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 1px; }
.mp-edit-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mp-edit-state { display: inline-flex; align-items: center; gap: 4px; flex: none; font: 500 12.5px/1.2 var(--pz-sans); color: var(--pz-a700); }
.mp-edit-row:not(.is-active) .mp-edit-state { color: var(--pz-muted); }
.mp-search {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--pz-line);
border-radius: 999px;
background: var(--pz-surface);
color: var(--pz-muted);
}
.mp-search:focus-within { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
.mp-search-input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--pz-ink); font: 400 15px/1.3 var(--pz-sans); }
.mp-results { list-style: none; margin: 0; padding: 4px; display: grid; gap: 2px; border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); background: var(--pz-surface); box-shadow: var(--pz-shadow); }
.mp-result {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 4px 8px;
border: 0;
border-radius: var(--pz-r-s);
background: transparent;
color: var(--pz-ink);
font: 400 14.5px/1.3 var(--pz-sans);
text-align: left;
cursor: pointer;
}
.mp-result svg { color: var(--pz-a700); flex: none; }
@media (hover: hover) and (pointer: fine) { .mp-result:hover { background: var(--pz-a50); } }
.mp-result:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 1px; }
.mp-result-year { margin-left: auto; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); font-variant-numeric: tabular-nums; }
+146
View File
@@ -0,0 +1,146 @@
import { useMemo, useState } from 'react'
import { Check, Search, UserPlus, X } from 'lucide-react'
import { t } from '../i18n/index.js'
import { firstNameLabel, fullNameLabel } from '../utils/personName.js'
import './MediaPeople.css'
const MAX_RESULTS = 6
const normalize = (value) => String(value || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
function PersonAvatar({ person }) {
return person?.photo
? <img className="mp-avatar" src={person.photo} alt="" />
: <span className="mp-avatar mp-avatar--initial" aria-hidden="true">{person?.firstName?.[0] || '?'}</span>
}
// Points posés sur la photo : un rond par visage, le prénom au survol ou au focus. En identification, le point de
// la personne choisie est mis en avant.
export function MediaPeopleMarkers({ people, getPerson, activeId = null, onOpenPerson }) {
return people.filter((tag) => tag.x != null && tag.y != null).map((tag) => {
const person = getPerson(tag.personId)
if (!person) return null
const name = firstNameLabel(person)
return (
<button
key={tag.personId}
type="button"
className={`mp-marker${activeId === tag.personId ? ' is-active' : ''}`}
style={{ left: `${tag.x * 100}%`, top: `${tag.y * 100}%` }}
onClick={(event) => { event.stopPropagation(); onOpenPerson?.(tag.personId) }}
aria-label={t('Voir la fiche de {name}', { name })}
>
<span className="mp-marker-name">{name}</span>
</button>
)
})
}
// « Sur la photo » : les personnes identifiées, chacune ouvre sa fiche. En identification : retirer, ajouter,
// choisir la personne dont on place le point.
export function MediaPeopleList({
people,
getPerson,
allPersons = [],
editing = false,
canPlace = false,
activeId = null,
onActivate,
onAdd,
onRemove,
onOpenPerson,
}) {
const [query, setQuery] = useState('')
const taken = useMemo(() => new Set(people.map((tag) => tag.personId)), [people])
const results = useMemo(() => {
const q = normalize(query.trim())
if (!q) return []
return allPersons
.filter((person) => !taken.has(person.id))
.filter((person) => normalize(`${person.firstName || ''} ${person.lastName || ''} ${person.nickname || ''}`).includes(q))
.slice(0, MAX_RESULTS)
}, [allPersons, query, taken])
const known = people.map((tag) => ({ tag, person: getPerson(tag.personId) })).filter((entry) => entry.person)
if (!editing) {
if (!known.length) return null
return (
<div className="mp-list" aria-label={t('Sur la photo')}>
<span className="mp-list-label">{t('Sur la photo')}</span>
{known.map(({ person }) => (
<button key={person.id} type="button" className="mp-chip" onClick={() => onOpenPerson?.(person.id)}>
<PersonAvatar person={person} />
<span>{firstNameLabel(person)}</span>
</button>
))}
</div>
)
}
return (
<div className="mp-edit">
{known.length > 0 && (
<ul className="mp-edit-list">
{known.map(({ tag, person }) => {
const active = activeId === person.id
const placed = tag.x != null
return (
<li key={person.id} className={`mp-edit-row${active ? ' is-active' : ''}`}>
<button
type="button"
className="mp-edit-pick"
onClick={() => onActivate?.(active ? null : person.id)}
aria-pressed={canPlace ? active : undefined}
disabled={!canPlace}
>
<PersonAvatar person={person} />
<span className="mp-edit-name">{fullNameLabel(person)}</span>
{canPlace && (
<span className="mp-edit-state">
{active ? t('Touchez son visage') : placed ? <><Check size={13} strokeWidth={2.4} aria-hidden="true" /> {t('Placé')}</> : t('Placer')}
</span>
)}
</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon mp-edit-remove" onClick={() => onRemove?.(person.id)} aria-label={t('Retirer {name}', { name: firstNameLabel(person) })}>
<X size={15} strokeWidth={2} />
</button>
</li>
)
})}
</ul>
)}
<div className="mp-search">
<Search size={15} strokeWidth={2} aria-hidden="true" />
<input
type="search"
className="mp-search-input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('Qui est sur la photo ?')}
aria-label={t('Chercher une personne de l’arbre')}
/>
</div>
{results.length > 0 && (
<ul className="mp-results" role="listbox" aria-label={t('Personnes trouvées')}>
{results.map((person) => (
<li key={person.id}>
<button
type="button"
role="option"
aria-selected="false"
className="mp-result"
onClick={() => { onAdd?.(person.id); setQuery('') }}
>
<UserPlus size={15} strokeWidth={2} aria-hidden="true" />
<PersonAvatar person={person} />
<span>{fullNameLabel(person)}</span>
{person.birthYear && <span className="mp-result-year">{person.birthYear}</span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}
+10 -1
View File
@@ -46,6 +46,11 @@
.mv-person-name { font: 400 16px/1.2 var(--pz-serif); color: var(--pz-ink); }
.mv-person-name em { font-style: italic; font-weight: 300; }
.mv-person-years { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); font-variant-numeric: tabular-nums; }
.mv-people { display: grid; justify-items: start; gap: 8px; }
.mv-people-btn { gap: 6px; }
.mv-people-edit { display: grid; gap: 10px; }
.mv-people-hint { margin: 0; font: 400 14px/1.45 var(--pz-sans); color: var(--pz-ink-2); }
.mv-people-notice { margin: 0; font: 400 13.5px/1.45 var(--pz-sans); color: var(--pz-success); }
.mv-source { margin: 0; font: 400 13px/1.5 var(--pz-sans); color: var(--pz-muted); overflow-wrap: anywhere; }
.mv-source a { color: var(--pz-a700); text-underline-offset: 3px; }
@@ -58,8 +63,12 @@
background: var(--pz-bg);
}
/* La boîte suit la taille rendue de l'image : les points des visages s'y posent en pourcentage */
.media-photo-frame { display: grid; place-items: center; }
.mv-photo-box { position: relative; max-width: 100%; }
.media-photo-frame img {
width: 100%;
max-width: 100%;
max-height: 68dvh;
object-fit: contain;
display: block;
+126 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Pencil, X } from 'lucide-react'
import { Pencil, UserPlus, X } from 'lucide-react'
import { MediaPeopleList, MediaPeopleMarkers } from './MediaPeople.jsx'
import {
buildOpenStreetMapEmbedUrl,
buildOpenStreetMapUrl,
@@ -23,8 +24,27 @@ const findLinkedEventId = (events, mediaId) => {
return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null
}
const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => {
const MediaViewer = ({
media,
person,
onClose,
canEdit = false,
onLoadLifeEvents,
onSave,
// Personnes identifiées : canTag ouvre l'identification (admin en direct, famille en proposition)
getPerson = () => null,
allPersons = [],
canTag = false,
onSavePeople,
onOpenPerson,
}) => {
const [mapState, setMapState] = useState({ loading: false, error: '', data: null })
const [tagging, setTagging] = useState(false)
const [draftPeople, setDraftPeople] = useState([])
const [activeTagId, setActiveTagId] = useState(null)
const [savingPeople, setSavingPeople] = useState(false)
const [peopleError, setPeopleError] = useState('')
const [peopleNotice, setPeopleNotice] = useState('')
const [editing, setEditing] = useState(false)
const [lifeEvents, setLifeEvents] = useState([])
const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null })
@@ -39,6 +59,58 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
}
const isCitation = media?.type === 'citation'
const savedPeople = Array.isArray(media?.people) ? media.people : []
const shownPeople = tagging ? draftPeople : savedPeople
const startTagging = () => {
setPeopleError('')
setPeopleNotice('')
setDraftPeople(savedPeople.map((tag) => ({ ...tag })))
setActiveTagId(null)
setTagging(true)
}
const cancelTagging = () => {
setTagging(false)
setActiveTagId(null)
setPeopleError('')
}
// Ajoutée, la personne devient celle dont on place le point (photo seulement)
const addTag = (personId) => {
setDraftPeople((current) => [...current, { personId, x: null, y: null }])
setActiveTagId(media?.type === 'photo' ? personId : null)
}
const removeTag = (personId) => {
setDraftPeople((current) => current.filter((tag) => tag.personId !== personId))
setActiveTagId((current) => (current === personId ? null : current))
}
const placeActiveTag = (event) => {
if (!tagging || !activeTagId) return
const box = event.currentTarget.getBoundingClientRect()
const clamp = (value) => Math.min(1, Math.max(0, value))
const x = Number(clamp((event.clientX - box.left) / box.width).toFixed(4))
const y = Number(clamp((event.clientY - box.top) / box.height).toFixed(4))
setDraftPeople((current) => current.map((tag) => (tag.personId === activeTagId ? { ...tag, x, y } : tag)))
setActiveTagId(null)
}
const savePeople = async () => {
setSavingPeople(true)
setPeopleError('')
try {
const result = await onSavePeople(draftPeople)
setTagging(false)
setActiveTagId(null)
if (result?.status === 'pending') setPeopleNotice(t('Merci. La personne qui gère l’arbre va relire votre proposition.'))
} catch (error) {
setPeopleError(getAccountErrorMessage(error))
} finally {
setSavingPeople(false)
}
}
const startEditing = async () => {
setSaveError('')
@@ -234,9 +306,18 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
)
}
// La boîte épouse l'image : les points (x, y en proportion) tombent pile sur les visages
return (
<div className="media-photo-frame">
<img src={mediaUrl} alt={media?.label || t('Photo')} />
<div className={`mv-photo-box${tagging ? ' is-tagging' : ''}${activeTagId ? ' is-placing' : ''}`} onClick={placeActiveTag}>
<img src={mediaUrl} alt={media?.label || t('Photo')} />
<MediaPeopleMarkers
people={shownPeople}
getPerson={getPerson}
activeId={activeTagId}
onOpenPerson={tagging ? (id) => setActiveTagId(id) : onOpenPerson}
/>
</div>
</div>
)
}
@@ -534,6 +615,48 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
</span>
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
</div>
{(mediaType === 'photo' || mediaType === 'video') && !editing && (
tagging ? (
<div className="mv-people-edit">
<p className="mv-people-hint">
{mediaType === 'photo'
? (activeTagId ? t('Touchez son visage sur la photo.') : t('Ajoutez les personnes, puis touchez leur visage sur la photo.'))
: t('Ajoutez les personnes de la vidéo.')}
</p>
<MediaPeopleList
people={draftPeople}
getPerson={getPerson}
allPersons={allPersons}
editing
canPlace={mediaType === 'photo'}
activeId={activeTagId}
onActivate={setActiveTagId}
onAdd={addTag}
onRemove={removeTag}
/>
{peopleError && <p className="mv-edit-error" role="alert">{peopleError}</p>}
<div className="mv-edit-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={cancelTagging} disabled={savingPeople}>
{t('Annuler')}
</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={savePeople} disabled={savingPeople}>
{t('Enregistrer')}
</button>
</div>
</div>
) : (
<div className="mv-people">
<MediaPeopleList people={savedPeople} getPerson={getPerson} onOpenPerson={onOpenPerson} />
{canTag && onSavePeople && (
<button type="button" className="pz-btn pz-btn--soft pz-btn--sm mv-people-btn" onClick={startTagging}>
<UserPlus size={15} strokeWidth={2} aria-hidden="true" />
{savedPeople.length ? t('Modifier les personnes') : mediaType === 'video' ? t('Qui est sur la vidéo ?') : t('Qui est sur la photo ?')}
</button>
)}
{peopleNotice && <p className="mv-people-notice" role="status">{peopleNotice}</p>}
</div>
)
)}
{mediaSource && !editing && (
<p className="mv-source">
{t('Source')}{' '}
+3
View File
@@ -168,6 +168,9 @@
.pc-tile--add { display: grid; place-items: center; color: var(--pz-a700); background: transparent; border: 1.5px dashed var(--pz-a300); }
.pc-list { display: grid; gap: 6px; }
/* Souvenirs des autres où la personne est identifiée */
.pc-tagged { display: grid; gap: 8px; }
.pc-tagged-title { margin: 4px 0 0; font: 500 13px/1.3 var(--pz-sans); color: var(--pz-muted); }
.pc-list-item {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px; cursor: pointer; text-align: left;
border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); background: var(--pz-surface);
+31 -4
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { useState, useEffect, useRef, forwardRef } from 'react'
import { createPortal } from 'react-dom'
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
import MomentSheet from './MomentSheet.jsx'
@@ -80,7 +80,7 @@ const PersonCard = ({
saveOnLeaveRef = null,
}) => {
// 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 [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || getPersonTaggedMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
const [lifeState, setLifeState] = useState({ personId: null, data: null, loading: false, failed: false })
const lifeRequestedFor = useRef(null)
@@ -461,6 +461,8 @@ const PersonCard = ({
return childFiliations[0]
})()
const personMedias = getPersonMedias(person.id)
// Souvenirs des autres où la personne est identifiée : après les siens, ouverts au nom de leur propriétaire
const taggedMedias = getPersonTaggedMedias(person.id).filter((m) => m.type === 'photo' || m.type === 'video')
// 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)
@@ -826,7 +828,7 @@ const PersonCard = ({
{/* ── Souvenirs ── */}
{tab === 'medias' && (
<div className="pc-souvenirs">
{personMedias.length === 0 && (
{personMedias.length === 0 && taggedMedias.length === 0 && (
<div className="pc-empty">
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: firstNameLabel(person) })}</p>
<p className="pz-small">{canManageMedia ? t('Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.') : t('Une photo, une anecdote, sa voix.')}</p>
@@ -872,6 +874,31 @@ const PersonCard = ({
</div>
)}
{taggedMedias.length > 0 && (
<section className="pc-tagged" aria-label={t('Avec {name}', { name: firstNameLabel(person) })}>
<h3 className="pc-tagged-title">{t('Avec {name}', { name: firstNameLabel(person) })}</h3>
<div className="pc-grid">
{taggedMedias.map((media) => {
const owner = getPersonById(media.personId)
return (
<button
key={media.id}
type="button"
className={`pc-tile pc-tile--${media.type}`}
onClick={() => owner && onMediaSelect?.(media, owner)}
title={media.label || TYPE_LABELS[media.type]}
>
{media.type === 'photo' && media.url
? <img src={media.url} alt={media.label || ''} loading="lazy" />
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
{media.label && <span className="pc-tile-label">{media.label}</span>}
</button>
)
})}
</div>
</section>
)}
{otherMedias.length > 0 && (
<div className="pc-list">
{otherMedias.map((media) => (
+1 -1
View File
@@ -156,7 +156,7 @@
.contrib-change-group { display: grid; gap: 6px; }
.contrib-group-header { font: 500 11.5px var(--pz-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--pz-faint); padding-top: 4px; }
.contrib-change-card { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--pz-line); border-radius: 12px; background: var(--pz-surface); transition: border-color 160ms ease, background-color 160ms ease; }
.contrib-change-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 10px 12px; border: 1px solid var(--pz-line); border-radius: 12px; background: var(--pz-surface); transition: border-color 160ms ease, background-color 160ms ease; }
.contrib-change-card--approved { border-color: #b9d8b2; background: var(--pz-success-bg); }
.contrib-change-card--rejected { border-color: #f1c9c4; background: var(--pz-danger-bg); }
.contrib-change-card--rejected .contrib-change-fields { opacity: .6; }
+23
View File
@@ -253,6 +253,10 @@ export function loadGraphData(graph, options = {}) {
displayOrder: Number.isFinite(media.displayOrder) ? media.displayOrder : index + 1,
isFeatured: Boolean(media.isFeatured),
mimeType: media.mimeType || null,
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
people: (Array.isArray(media.people) ? media.people : [])
.filter((tag) => personIdSet.has(tag.personId))
.map((tag) => ({ personId: tag.personId, x: tag.x ?? null, y: tag.y ?? null })),
}
})
@@ -307,6 +311,25 @@ export function getPersonMedias(personId) {
return mediasByPerson.get(personId) || EMPTY_MEDIAS
}
// Souvenirs des autres où la personne est identifiée (sa fiche les montre après les siens)
let taggedIndexSource = null
let taggedByPerson = new Map()
export function getPersonTaggedMedias(personId) {
if (taggedIndexSource !== medias) {
taggedIndexSource = medias
taggedByPerson = new Map()
for (const media of medias) {
for (const tag of media.people || []) {
if (tag.personId === media.personId) continue
if (!taggedByPerson.has(tag.personId)) taggedByPerson.set(tag.personId, [])
taggedByPerson.get(tag.personId).push(media)
}
}
}
return taggedByPerson.get(personId) || EMPTY_MEDIAS
}
let personsIndexSource = null
let personsById = new Map()
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getPersonById, getPersonMedias, loadGraphData } from './mockData'
import { getPersonById, getPersonMedias, getPersonTaggedMedias, loadGraphData } from './mockData'
describe('loadGraphData avatar mapping', () => {
it('uses dedicated avatarUrlPath and does not derive person photo from medias', () => {
@@ -90,3 +90,43 @@ describe('loadGraphData, surnom', () => {
expect(getPersonById('p-2').nickname).toBeNull()
})
})
describe('personnes identifiées sur un souvenir', () => {
it('montre le souvenir aux personnes identifiées, pas à son propriétaire, et écarte les inconnues', () => {
const person = (id, firstName) => ({ id, firstName, lastName: 'Martin', birthDate: null, deathDate: null, notes: null, avatarUrlPath: null })
loadGraphData(
{
persons: [person('p-1', 'Alice'), person('p-2', 'Bob'), person('p-3', 'Chloé')],
unions: [],
filiations: [],
medias: [
{
id: 'm-1',
personId: 'p-1',
type: 'photo',
urlPath: '/trees/t-1/media/m-1',
caption: 'Mariage',
displayOrder: 1,
isFeatured: false,
people: [
{ personId: 'p-1', x: 0.2, y: 0.3 },
{ personId: 'p-2', x: null, y: null },
{ personId: 'disparue', x: 0.5, y: 0.5 },
],
},
],
annotations: [],
},
{ mediaToken: 'tok', apiBaseUrl: '' },
)
expect(getPersonMedias('p-1').map((m) => m.id)).toEqual(['m-1'])
expect(getPersonTaggedMedias('p-1')).toEqual([])
expect(getPersonTaggedMedias('p-2').map((m) => m.id)).toEqual(['m-1'])
expect(getPersonTaggedMedias('p-3')).toEqual([])
expect(getPersonMedias('p-1')[0].people).toEqual([
{ personId: 'p-1', x: 0.2, y: 0.3 },
{ personId: 'p-2', x: null, y: null },
])
})
})
+20 -2
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia, updatePersonMedia, fetchPersonLife } from '../api/treeApi'
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia, updatePersonMedia, updateMediaPeople, fetchPersonLife } from '../api/treeApi'
import { getPersonMedias } from '../data/mockData'
import { fileToBase64, getAccountErrorMessage } from '../utils/errorMessages'
import { getUploadMimeType, resolveUploadMediaType, validateMediaFile } from '../utils/mediaUpload'
import { saveContributorName } from '../utils/contributorName'
import { readContributorName, saveContributorName } from '../utils/contributorName'
import { t } from '../i18n/index.js'
// Souvenir envoyé par la famille : en attente de la relecture du propriétaire
@@ -285,6 +285,23 @@ export function useMediaManager({
await refreshAfterMediaMutation(personId)
}
// Personnes identifiées sur un souvenir : administrateur, appliqué et arbre rafraîchi ; famille, une
// proposition (status « pending »), rien ne change avant la relecture
const handleUpdateMediaPeople = async (ownerPersonId, mediaId, people) => {
if (!canUploadMedia || !treeContext.treeId || !treeContext.accessToken) {
throw new Error('forbidden')
}
const name = canEditCurrentTree ? '' : readContributorName()
const result = await updateMediaPeople(
treeContext.treeId,
mediaId,
{ people, ...(name ? { submittedByLabel: name } : {}) },
treeContext.accessToken,
)
if (result?.status === 'approved') await refreshAfterMediaMutation(ownerPersonId)
return result
}
const loadPersonLifeEvents = (personId) => fetchPersonLife(treeContext.treeId, personId, treeContext.accessToken)
const resetMediaManager = () => {
@@ -302,6 +319,7 @@ export function useMediaManager({
refreshMomentMedia,
handleDeletePersonMedia,
handleUpdatePersonMedia,
handleUpdateMediaPeople,
loadPersonLifeEvents,
resetMediaManager,
}
+25
View File
@@ -8,4 +8,29 @@ export default {
"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.",
// Personnes identifiées sur un souvenir
"Avec {name}": "With {name}",
"Sur la photo": "In this photo",
"Voir la fiche de {name}": "Open {name}'s card",
"Touchez son visage": "Tap their face",
"Placé": "Placed",
"Placer": "Place",
"Retirer {name}": "Remove {name}",
"Qui est sur la photo ?": "Who is in this photo?",
"Qui est sur la vidéo ?": "Who is in this video?",
"Chercher une personne de l’arbre": "Search someone in the tree",
"Personnes trouvées": "People found",
"Merci. La personne qui gère l’arbre va relire votre proposition.": "Thank you. The person who manages the tree will review your suggestion.",
"Touchez son visage sur la photo.": "Tap their face in the photo.",
"Ajoutez les personnes, puis touchez leur visage sur la photo.": "Add the people, then tap their faces in the photo.",
"Ajoutez les personnes de la vidéo.": "Add the people in the video.",
"Modifier les personnes": "Edit people",
"personnes sur un souvenir": "people in a memory",
"Personnes sur un souvenir": "People in a memory",
"Photo de {name}": "Photo of {name}",
"Sur les photos": "In the photos",
"Photo du souvenir": "Memory photo",
"Sur la photo, {names}": "In the photo, {names}",
"Plus personne sur la photo": "No one in the photo anymore",
"Avant, {names}": "Before, {names}",
}