From f65273e95a6b0909ece87608b115769b1b4feed6 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 20:32:22 +0200 Subject: [PATCH] =?UTF-8?q?Personnes=20identifi=C3=A9es=20sur=20un=20souve?= =?UTF-8?q?nir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 30 ++ backend/prisma/schema.prisma | 23 ++ backend/src/lib/entity-deletion.ts | 10 +- backend/src/lib/media-people.ts | 73 +++++ backend/src/routes/contributions.ts | 137 ++++++++- backend/src/routes/graph.ts | 9 +- backend/src/routes/media.ts | 3 +- backend/src/utils/graph-payload.ts | 15 +- backend/test/api-routes.test.ts | 5 + backend/test/api-security.integration.test.ts | 5 + backend/test/avatar-crop.test.ts | 5 + backend/test/e2e-journeys.test.ts | 5 + backend/test/graph-payload.test.ts | 30 ++ backend/test/immich-config.test.ts | 5 + backend/test/immich-unconfigured.test.ts | 5 + backend/test/immich.test.ts | 6 + backend/test/media-people-lib.test.ts | 32 +++ backend/test/media-people.test.ts | 259 ++++++++++++++++++ backend/test/media-update.test.ts | 5 + frontend/src/App.jsx | 17 ++ frontend/src/api/treeApi.js | 15 + frontend/src/components/ContributionPanel.jsx | 37 ++- frontend/src/components/MediaPeople.css | 123 +++++++++ frontend/src/components/MediaPeople.jsx | 146 ++++++++++ frontend/src/components/MediaViewer.css | 11 +- frontend/src/components/MediaViewer.jsx | 129 ++++++++- frontend/src/components/PersonCard.css | 3 + frontend/src/components/PersonCard.jsx | 35 ++- frontend/src/components/SettingsPanels.css | 2 +- frontend/src/data/mockData.js | 23 ++ frontend/src/data/mockData.test.js | 42 ++- frontend/src/hooks/useMediaManager.js | 22 +- frontend/src/i18n/en/souvenir.js | 25 ++ 33 files changed, 1270 insertions(+), 22 deletions(-) create mode 100644 backend/prisma/migrations/20261021_001_souvenirs_personnes/migration.sql create mode 100644 backend/src/lib/media-people.ts create mode 100644 backend/test/media-people-lib.test.ts create mode 100644 backend/test/media-people.test.ts create mode 100644 frontend/src/components/MediaPeople.css create mode 100644 frontend/src/components/MediaPeople.jsx diff --git a/backend/prisma/migrations/20261021_001_souvenirs_personnes/migration.sql b/backend/prisma/migrations/20261021_001_souvenirs_personnes/migration.sql new file mode 100644 index 0000000..4260051 --- /dev/null +++ b/backend/prisma/migrations/20261021_001_souvenirs_personnes/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 86d0b3d..6875c75 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/lib/entity-deletion.ts b/backend/src/lib/entity-deletion.ts index e9b688c..b30cba3 100644 --- a/backend/src/lib/entity-deletion.ts +++ b/backend/src/lib/entity-deletion.ts @@ -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([ diff --git a/backend/src/lib/media-people.ts b/backend/src/lib/media-people.ts new file mode 100644 index 0000000..8d22899 --- /dev/null +++ b/backend/src/lib/media-people.ts @@ -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): 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 { + 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 { + 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 +} diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 4dcd8bb..3d6f639 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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, 'entityType'> & { - entityType: z.infer['entityType'] | 'life_event' + entityType: z.infer['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 = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 } +const ENTITY_ORDER: Record = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4, media_people: 5 } function byApplicationOrder(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) { diff --git a/backend/src/routes/graph.ts b/backend/src/routes/graph.ts index cd55289..2cfdded 100644 --- a/backend/src/routes/graph.ts +++ b/backend/src/routes/graph.ts @@ -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, diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 99cee28..535f3ac 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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) diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 745919c..82b8fee 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -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[] = [], ): 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>() + 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, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 53faa60..d9f56cd 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -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(), diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 5bd9ac4..2c23d4a 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -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(), diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts index 5a1a2d9..90d45c5 100644 --- a/backend/test/avatar-crop.test.ts +++ b/backend/test/avatar-crop.test.ts @@ -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(), }, diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index cbf4a3c..ac7fe9c 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -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(), diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index e534aec..88429d6 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -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([]) + }) +}) diff --git a/backend/test/immich-config.test.ts b/backend/test/immich-config.test.ts index 46b7175..3d5f090 100644 --- a/backend/test/immich-config.test.ts +++ b/backend/test/immich-config.test.ts @@ -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(), }, diff --git a/backend/test/immich-unconfigured.test.ts b/backend/test/immich-unconfigured.test.ts index 5c0695f..9702573 100644 --- a/backend/test/immich-unconfigured.test.ts +++ b/backend/test/immich-unconfigured.test.ts @@ -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(), }, diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index d9b28b5..c8d2b62 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -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, { get: (_target, name: string) => model(name), diff --git a/backend/test/media-people-lib.test.ts b/backend/test/media-people-lib.test.ts new file mode 100644 index 0000000..6560499 --- /dev/null +++ b/backend/test/media-people-lib.test.ts @@ -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() + }) +}) diff --git a/backend/test/media-people.test.ts b/backend/test/media-people.test.ts new file mode 100644 index 0000000..bf567de --- /dev/null +++ b/backend/test/media-people.test.ts @@ -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() + 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 { + 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() + }) +}) diff --git a/backend/test/media-update.test.ts b/backend/test/media-update.test.ts index 9cd7d04..64699c5 100644 --- a/backend/test/media-update.test.ts +++ b/backend/test/media-update.test.ts @@ -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(), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 902ecf5..e6a1407 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) + }} /> )} (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 (
)} + {peoplePreview && ( +
+ {peoplePreview.url && ( + + {t('Photo { e.currentTarget.style.display = 'none' }} /> + + )} +

+ {peoplePreview.after.length ? t('Sur la photo, {names}', { names: peoplePreview.after.join(', ') }) : t('Plus personne sur la photo')} +

+ {peoplePreview.before.length > 0 && ( +

{t('Avant, {names}', { names: peoplePreview.before.join(', ') })}

+ )} +
+ )} + {momentPreview && (
{momentPreview.when && {momentPreview.when}} diff --git a/frontend/src/components/MediaPeople.css b/frontend/src/components/MediaPeople.css new file mode 100644 index 0000000..04010cf --- /dev/null +++ b/frontend/src/components/MediaPeople.css @@ -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; } diff --git a/frontend/src/components/MediaPeople.jsx b/frontend/src/components/MediaPeople.jsx new file mode 100644 index 0000000..6d8c197 --- /dev/null +++ b/frontend/src/components/MediaPeople.jsx @@ -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 + ? + : +} + +// 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 ( + + ) + }) +} + +// « 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 ( +
+ {t('Sur la photo')} + {known.map(({ person }) => ( + + ))} +
+ ) + } + + return ( +
+ {known.length > 0 && ( +
    + {known.map(({ tag, person }) => { + const active = activeId === person.id + const placed = tag.x != null + return ( +
  • + + +
  • + ) + })} +
+ )} +
+
+ {results.length > 0 && ( +
    + {results.map((person) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/MediaViewer.css b/frontend/src/components/MediaViewer.css index 785ef68..c526b45 100644 --- a/frontend/src/components/MediaViewer.css +++ b/frontend/src/components/MediaViewer.css @@ -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; diff --git a/frontend/src/components/MediaViewer.jsx b/frontend/src/components/MediaViewer.jsx index 28d064c..79bbbf5 100644 --- a/frontend/src/components/MediaViewer.jsx +++ b/frontend/src/components/MediaViewer.jsx @@ -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 (
- {media?.label +
+ {media?.label + setActiveTagId(id) : onOpenPerson} + /> +
) } @@ -534,6 +615,48 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents {typeLabels[mediaType] || mediaType}
+ {(mediaType === 'photo' || mediaType === 'video') && !editing && ( + tagging ? ( +
+

+ {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.')} +

+ + {peopleError &&

{peopleError}

} +
+ + +
+
+ ) : ( +
+ + {canTag && onSavePeople && ( + + )} + {peopleNotice &&

{peopleNotice}

} +
+ ) + )} {mediaSource && !editing && (

{t('Source')}{' '} diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 0c850d3..4b65544 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -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); diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 0c9717a..c0ceed4 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -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' && (

- {personMedias.length === 0 && ( + {personMedias.length === 0 && taggedMedias.length === 0 && (

{t('Pas encore de souvenir pour {name}.', { name: firstNameLabel(person) })}

{canManageMedia ? t('Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.') : t('Une photo, une anecdote, sa voix.')}

@@ -872,6 +874,31 @@ const PersonCard = ({
)} + {taggedMedias.length > 0 && ( +
+

{t('Avec {name}', { name: firstNameLabel(person) })}

+
+ {taggedMedias.map((media) => { + const owner = getPersonById(media.personId) + return ( + + ) + })} +
+
+ )} + {otherMedias.length > 0 && (
{otherMedias.map((media) => ( diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index 8dd5b81..a338979 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -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; } diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index eefa6ba..76a6d06 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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() diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index a109f9e..8163809 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -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 }, + ]) + }) +}) \ No newline at end of file diff --git a/frontend/src/hooks/useMediaManager.js b/frontend/src/hooks/useMediaManager.js index 8318021..a780ab7 100644 --- a/frontend/src/hooks/useMediaManager.js +++ b/frontend/src/hooks/useMediaManager.js @@ -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, } diff --git a/frontend/src/i18n/en/souvenir.js b/frontend/src/i18n/en/souvenir.js index 3db6fb5..978018b 100644 --- a/frontend/src/i18n/en/souvenir.js +++ b/frontend/src/i18n/en/souvenir.js @@ -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}", }