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 f9bcbd9..e90819c 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -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); diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index eae030b..0d6e016 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) @@ -813,7 +815,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.')}

@@ -859,6 +861,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}", }