Merge pull request #131 from IliesAllali/feat/souvenirs-personnes

Personnes identifiées sur un souvenir
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-21 20:48:19 +02:00
commit 06d0404903
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
@@ -158,6 +158,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)
@@ -813,7 +815,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>
@@ -859,6 +861,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}",
}