mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #131 from IliesAllali/feat/souvenirs-personnes
Personnes identifiées sur un souvenir
This commit is contained in:
33 files changed
+1270
-22
No files matched your search
@@ -0,0 +1,30 @@
|
||||
-- Personnes identifiées sur un souvenir (feat/souvenirs-personnes). Migration additive : nouvelle table, nouvelle valeur d'énumération.
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ContributionEntityType" ADD VALUE 'media_people';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "media_person_tags" (
|
||||
"id" TEXT NOT NULL,
|
||||
"treeId" TEXT NOT NULL,
|
||||
"mediaId" TEXT NOT NULL,
|
||||
"personId" TEXT NOT NULL,
|
||||
"x" DOUBLE PRECISION,
|
||||
"y" DOUBLE PRECISION,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "media_person_tags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "media_person_tags_treeId_idx" ON "media_person_tags"("treeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "media_person_tags_treeId_personId_idx" ON "media_person_tags"("treeId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "media_person_tags_mediaId_personId_key" ON "media_person_tags"("mediaId", "personId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "media_person_tags" ADD CONSTRAINT "media_person_tags_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
// Personnes identifiées sur un souvenir. La liste envoyée remplace la précédente : retirer quelqu'un, c'est
|
||||
// renvoyer la liste sans lui. Le point (x, y) est facultatif, en proportion de l'image (0 à 1), et va par paire.
|
||||
export const MAX_PEOPLE_PER_MEDIA = 50
|
||||
|
||||
const coordinate = z.number().min(0).max(1).nullable().optional()
|
||||
|
||||
export const mediaPersonSchema = z
|
||||
.object({
|
||||
personId: z.string().min(1).max(64),
|
||||
x: coordinate,
|
||||
y: coordinate,
|
||||
})
|
||||
.refine((value) => (value.x == null) === (value.y == null), { message: 'point_requires_x_and_y' })
|
||||
|
||||
export const mediaPeopleSchema = z
|
||||
.array(mediaPersonSchema)
|
||||
.max(MAX_PEOPLE_PER_MEDIA)
|
||||
.refine((people) => new Set(people.map((person) => person.personId)).size === people.length, { message: 'duplicate_person' })
|
||||
|
||||
export type MediaPerson = { personId: string; x: number | null; y: number | null }
|
||||
|
||||
export function normalizeMediaPeople(people: z.infer<typeof mediaPeopleSchema>): MediaPerson[] {
|
||||
return people.map((person) => ({ personId: person.personId, x: person.x ?? null, y: person.y ?? null }))
|
||||
}
|
||||
|
||||
export async function readMediaPeople(db: Db, treeId: string, mediaId: string): Promise<MediaPerson[]> {
|
||||
const tags = await db.mediaPersonTag.findMany({
|
||||
where: { treeId, mediaId },
|
||||
select: { personId: true, x: true, y: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
return tags
|
||||
}
|
||||
|
||||
// Remplace les personnes d'un souvenir. Une personne supprimée entre l'envoi et la relecture est écartée
|
||||
// sans faire échouer le reste. Renvoie la liste réellement enregistrée.
|
||||
export async function replaceMediaPeople(
|
||||
db: Db,
|
||||
treeId: string,
|
||||
mediaId: string,
|
||||
people: MediaPerson[],
|
||||
actorUserId: string | null,
|
||||
): Promise<MediaPerson[]> {
|
||||
const ids = people.map((person) => person.personId)
|
||||
const existing = ids.length
|
||||
? await db.person.findMany({ where: { treeId, id: { in: ids }, deletedAt: null }, select: { id: true } })
|
||||
: []
|
||||
const alive = new Set(existing.map((person) => person.id))
|
||||
const kept = people.filter((person) => alive.has(person.personId))
|
||||
|
||||
await db.mediaPersonTag.deleteMany({ where: { treeId, mediaId } })
|
||||
if (kept.length) {
|
||||
// Une date par rang : l'ordre d'affichage suit celui de la liste envoyée
|
||||
const base = Date.now()
|
||||
await db.mediaPersonTag.createMany({
|
||||
data: kept.map((person, index) => ({
|
||||
treeId,
|
||||
mediaId,
|
||||
personId: person.personId,
|
||||
x: person.x,
|
||||
y: person.y,
|
||||
createdBy: actorUserId,
|
||||
createdAt: new Date(base + index),
|
||||
})),
|
||||
})
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
|
||||
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
|
||||
import { removeStoredFile } from '../lib/media-files.js'
|
||||
import { syncPersonMediaOrder } from '../lib/media-order.js'
|
||||
import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js'
|
||||
|
||||
import type { Actor, MembershipRole } from '../types/auth.js'
|
||||
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
|
||||
@@ -155,9 +156,10 @@ const reorderSchema = z.object({
|
||||
|
||||
type ContributionStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
// Les souvenirs et les moments arrivent par leurs propres routes, jamais par une session envoyée en bloc
|
||||
// Les souvenirs, les moments et les personnes d'un souvenir arrivent par leurs propres routes, jamais par une
|
||||
// session envoyée en bloc
|
||||
type ParsedChange = Omit<z.infer<typeof contributionChangeSchema>, 'entityType'> & {
|
||||
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event'
|
||||
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event' | 'media_people'
|
||||
}
|
||||
|
||||
type TxClient = Prisma.TransactionClient
|
||||
@@ -190,7 +192,7 @@ function recordTempRef(after: unknown, createdId: string, refs: RefMap) {
|
||||
}
|
||||
|
||||
// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle
|
||||
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
|
||||
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4, media_people: 5 }
|
||||
function byApplicationOrder<T extends { entityType: string; action: string }>(changes: T[]): T[] {
|
||||
const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1)
|
||||
return changes.map((change, index) => ({ change, index }))
|
||||
@@ -342,6 +344,26 @@ async function applyContributionChange(
|
||||
return { entityId: change.entityId }
|
||||
}
|
||||
|
||||
// Personnes identifiées sur un souvenir : la liste proposée remplace l'actuelle. Souvenir supprimé entre
|
||||
// l'envoi et la relecture : rien à faire, la relecture continue.
|
||||
if (change.entityType === 'media_people') {
|
||||
if (change.action !== 'update' || !change.entityId) {
|
||||
throw new ContributionRouteError(400, 'unsupported_change_action_for_media_people')
|
||||
}
|
||||
const people = mediaPeopleSchema.safeParse(isRecord(change.after) ? change.after.people : undefined)
|
||||
if (!people.success) {
|
||||
throw new ContributionRouteError(400, 'invalid_media_people')
|
||||
}
|
||||
const media = await tx.mediaItem.findFirst({
|
||||
where: { id: change.entityId, treeId, status: 'approved', deletedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (media) {
|
||||
await replaceMediaPeople(tx, treeId, change.entityId, normalizeMediaPeople(people.data), actorUserId)
|
||||
}
|
||||
return { entityId: change.entityId }
|
||||
}
|
||||
|
||||
// Souvenir envoyé par la famille : le fichier existe déjà, on le rend visible
|
||||
if (change.entityType === 'media') {
|
||||
if (change.action !== 'create' || !change.entityId) {
|
||||
@@ -1063,6 +1085,115 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
|
||||
}
|
||||
|
||||
export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Personnes identifiées sur un souvenir. Administrateur : enregistré tout de suite. Famille (membre, lien
|
||||
// de partage) : une proposition, appliquée à la relecture, ou tout de suite si l'arbre l'autorise.
|
||||
app.put('/trees/:id/media/:mediaId/people', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = z.object({ id: z.string().min(1), mediaId: z.string().min(1) }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
const payload = z.object({
|
||||
people: mediaPeopleSchema,
|
||||
submittedByLabel: z.string().trim().min(1).max(120).optional(),
|
||||
}).safeParse(request.body)
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
const treeId = params.data.id
|
||||
const mediaId = params.data.mediaId
|
||||
|
||||
try {
|
||||
const context = await resolveSubmissionContext(app, request.actor, treeId)
|
||||
const actor = request.actor
|
||||
const membership = actor?.kind === 'user'
|
||||
? await app.prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: actor.userId } }, select: { role: true } })
|
||||
: null
|
||||
const isAdmin = Boolean(membership && hasAdminRight(membership.role))
|
||||
|
||||
const media = await app.prisma.mediaItem.findFirst({
|
||||
where: { id: mediaId, treeId, status: 'approved', deletedAt: null },
|
||||
select: { id: true, personId: true },
|
||||
})
|
||||
if (!media) {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
const people = normalizeMediaPeople(payload.data.people)
|
||||
const known = people.length
|
||||
? await app.prisma.person.count({ where: { treeId, id: { in: people.map((person) => person.personId) }, deletedAt: null } })
|
||||
: 0
|
||||
if (known !== people.length) {
|
||||
return reply.code(400).send({ error: 'invalid_person_reference' })
|
||||
}
|
||||
|
||||
if (context.autoStatus === 'pending') {
|
||||
const pendingCount = await app.prisma.contributionSession.count({ where: { treeId, status: 'pending' } })
|
||||
if (pendingCount >= MAX_PENDING_SESSIONS_PER_TREE) {
|
||||
return reply.code(429).send({ error: 'too_many_pending_contributions' })
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const saved = await app.prisma.$transaction(async (tx) => {
|
||||
const before = await tx.mediaPersonTag.findMany({
|
||||
where: { treeId, mediaId },
|
||||
select: { personId: true, x: true, y: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
const applied = context.autoStatus === 'approved'
|
||||
? await replaceMediaPeople(tx, treeId, mediaId, people, context.submittedByUserId)
|
||||
: null
|
||||
|
||||
// L'administrateur n'a rien à relire : pas de session, comme pour ses propres souvenirs
|
||||
if (!isAdmin) {
|
||||
const session = await tx.contributionSession.create({
|
||||
data: {
|
||||
treeId,
|
||||
submittedByUserId: context.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
|
||||
mode: context.mode,
|
||||
status: context.autoStatus,
|
||||
title: 'Personnes sur un souvenir',
|
||||
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
|
||||
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
|
||||
},
|
||||
})
|
||||
await tx.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'media_people',
|
||||
entityId: mediaId,
|
||||
action: 'update',
|
||||
beforeJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people: before }),
|
||||
afterJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people }),
|
||||
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
actorType: context.actorType,
|
||||
actorId: context.actorId,
|
||||
action: 'media_people_updated',
|
||||
entityType: 'media_item',
|
||||
entityId: mediaId,
|
||||
payloadJson: { status: context.autoStatus, count: people.length },
|
||||
},
|
||||
})
|
||||
return applied
|
||||
})
|
||||
|
||||
return reply.send({ status: context.autoStatus, people: saved ?? people })
|
||||
} catch (error) {
|
||||
if (error instanceof ContributionRouteError) {
|
||||
return reply.code(error.statusCode).send({ error: error.code })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/trees/:id/contributions/sessions', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = treeIdParamSchema.safeParse(request.params)
|
||||
if (!params.success) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
|
||||
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '@prisma/client'
|
||||
|
||||
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
|
||||
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
|
||||
@@ -53,6 +53,8 @@ export type GraphPayload = {
|
||||
source: string | null
|
||||
displayOrder: number
|
||||
isFeatured: boolean
|
||||
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
|
||||
people: Array<{ personId: string; x: number | null; y: number | null }>
|
||||
}>
|
||||
annotations: Array<{
|
||||
id: string
|
||||
@@ -81,7 +83,17 @@ export function buildGraphPayload(
|
||||
links: ParentChildLink[],
|
||||
medias: MediaItem[] = [],
|
||||
annotations: Annotation[] = [],
|
||||
mediaPeople: Pick<MediaPersonTag, 'mediaId' | 'personId' | 'x' | 'y'>[] = [],
|
||||
): GraphPayload {
|
||||
// Une personne supprimée n'est plus montrée sur les photos où elle était identifiée
|
||||
const livingIds = new Set(persons.map((person) => person.id))
|
||||
const peopleByMedia = new Map<string, Array<{ personId: string; x: number | null; y: number | null }>>()
|
||||
for (const tag of mediaPeople) {
|
||||
if (!livingIds.has(tag.personId)) continue
|
||||
const list = peopleByMedia.get(tag.mediaId) ?? []
|
||||
list.push({ personId: tag.personId, x: tag.x, y: tag.y })
|
||||
peopleByMedia.set(tag.mediaId, list)
|
||||
}
|
||||
// Keep DB order from route queries to preserve sibling and union ordering semantics.
|
||||
return {
|
||||
persons: persons.map((person) => ({
|
||||
@@ -131,6 +143,7 @@ export function buildGraphPayload(
|
||||
source: media.source,
|
||||
displayOrder: media.displayOrder,
|
||||
isFeatured: media.isFeatured,
|
||||
people: peopleByMedia.get(media.id) ?? [],
|
||||
})),
|
||||
annotations: annotations.map((ann) => ({
|
||||
id: ann.id,
|
||||
|
||||
@@ -95,6 +95,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -91,6 +91,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -26,6 +26,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
mediaItem: {
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
@@ -1201,6 +1206,7 @@ describe('Immich integration: suppression logique d’une personne', () => {
|
||||
calls.push({ model: name, method: 'updateMany', args })
|
||||
return { count: 0 }
|
||||
},
|
||||
deleteMany: async () => ({ count: 0 }),
|
||||
})
|
||||
const db = new Proxy({} as Record<string, unknown>, {
|
||||
get: (_target, name: string) => model(name),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
tree: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
count: vi.fn(async () => 0),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
treeSettings: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
treeAccessPasswords: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
treeSharePassword: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
userTreeAccess: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
person: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
union: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
parentChildLink: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
mediaItem: {
|
||||
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
lifeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contributionChange: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) }
|
||||
})
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
const URL_PEOPLE = '/trees/tree-1/media/media-1/people'
|
||||
|
||||
beforeAll(async () => {
|
||||
applyTestEnv()
|
||||
const module = await import('../src/app.js')
|
||||
createApp = module.createApp
|
||||
})
|
||||
|
||||
function setRole(role: string) {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||||
treeId: 'tree-1',
|
||||
userId: 'user-1',
|
||||
role,
|
||||
contributionModeOverride: null,
|
||||
tree: { deletedAt: null, rootPersonId: null, settings: { memberContributionPolicy: 'pending' } },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
||||
setRole('owner')
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', personId: 'person-1' })
|
||||
prismaMock.person.count.mockImplementation(async ({ where }: any) => where.id.in.length)
|
||||
prismaMock.person.findMany.mockImplementation(async ({ where }: any) => where.id.in.map((id: string) => ({ id })))
|
||||
prismaMock.mediaPersonTag.findMany.mockResolvedValue([{ personId: 'person-2', x: null, y: null }])
|
||||
prismaMock.mediaPersonTag.deleteMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.mediaPersonTag.createMany.mockResolvedValue({ count: 2 })
|
||||
prismaMock.contributionSession.count.mockResolvedValue(0)
|
||||
prismaMock.contributionSession.create.mockResolvedValue({ id: 'session-1' })
|
||||
prismaMock.contributionChange.create.mockResolvedValue({ id: 'change-1' })
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock))
|
||||
})
|
||||
|
||||
const PEOPLE = [
|
||||
{ personId: 'person-2', x: 0.25, y: 0.4 },
|
||||
{ personId: 'person-3' },
|
||||
]
|
||||
|
||||
async function put(app: FastifyInstance, payload: unknown) {
|
||||
const token = await createUserToken(app)
|
||||
return app.inject({ method: 'PUT', url: URL_PEOPLE, headers: { authorization: `Bearer ${token}` }, payload })
|
||||
}
|
||||
|
||||
describe('PUT media people (identifier des personnes sur un souvenir)', () => {
|
||||
it('saves directly for an admin, without a contribution session', async () => {
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().status).toBe('approved')
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', mediaId: 'media-1' } })
|
||||
const created = prismaMock.mediaPersonTag.createMany.mock.calls[0][0].data
|
||||
expect(created.map((tag: any) => [tag.personId, tag.x, tag.y])).toEqual([['person-2', 0.25, 0.4], ['person-3', null, null]])
|
||||
expect(prismaMock.contributionSession.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('turns a contributor request into a pending proposal, nothing applied', async () => {
|
||||
setRole('contributor')
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE, submittedByLabel: 'Lucie' })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().status).toBe('pending')
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.contributionSession.create.mock.calls[0][0].data).toMatchObject({ status: 'pending', submittedByLabel: 'Lucie' })
|
||||
const change = prismaMock.contributionChange.create.mock.calls[0][0].data
|
||||
expect(change).toMatchObject({ entityType: 'media_people', entityId: 'media-1', action: 'update' })
|
||||
expect(change.beforeJson.people).toEqual([{ personId: 'person-2', x: null, y: null }])
|
||||
expect(change.afterJson.people).toHaveLength(2)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a person from another tree', async () => {
|
||||
prismaMock.person.count.mockResolvedValueOnce(1)
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(400)
|
||||
expect(response.json().error).toBe('invalid_person_reference')
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('answers 404 for a missing or pending souvenir', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(404)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects a duplicate person, a lone coordinate and a point outside the photo', async () => {
|
||||
const app = createApp()
|
||||
const duplicate = await put(app, { people: [{ personId: 'person-2' }, { personId: 'person-2' }] })
|
||||
expect(duplicate.statusCode).toBe(400)
|
||||
const lone = await put(app, { people: [{ personId: 'person-2', x: 0.5 }] })
|
||||
expect(lone.statusCode).toBe(400)
|
||||
const outside = await put(app, { people: [{ personId: 'person-2', x: 1.2, y: 0.5 }] })
|
||||
expect(outside.statusCode).toBe(400)
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('clears every identification with an empty list', async () => {
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: [] })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalled()
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires a session', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const response = await app.inject({ method: 'PUT', url: URL_PEOPLE, payload: { people: [] } })
|
||||
expect(response.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -3309,6 +3309,23 @@ function App() {
|
||||
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
|
||||
})
|
||||
}}
|
||||
getPerson={(id) => persons.find((p) => p.id === id) || null}
|
||||
allPersons={persons}
|
||||
canTag={Boolean((tree.canEditCurrentTree || tree.canSubmitContribution) && !isDemoMode)}
|
||||
onSavePeople={async (people) => {
|
||||
const { person, media: current } = tree.selectedMedia
|
||||
const result = await media.handleUpdateMediaPeople(person.id, current.id, people)
|
||||
// Appliqué tout de suite (administrateur) : la visionneuse rouvre le souvenir avec ses personnes
|
||||
if (result?.status === 'approved') tree.setSelectedMedia({ person, media: { ...current, people: result.people ?? people } })
|
||||
return result
|
||||
}}
|
||||
onOpenPerson={async (personId) => {
|
||||
const target = persons.find((p) => p.id === personId)
|
||||
if (!target) return
|
||||
tree.setSelectedMedia(null)
|
||||
if (!(await chooseSelectedPerson(target))) return
|
||||
galaxyZoomApiRef.current?.focusPerson?.(target.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MediaManagerPanel
|
||||
|
||||
@@ -470,6 +470,21 @@ export async function updatePersonMedia(treeId, personId, mediaId, payload, toke
|
||||
return data
|
||||
}
|
||||
|
||||
// Personnes identifiées sur un souvenir : la liste remplace la précédente. Administrateur, enregistré tout de
|
||||
// suite ; famille, une proposition (status « pending ») relue par l'administrateur
|
||||
export async function updateMediaPeople(treeId, mediaId, payload, token) {
|
||||
return apiRequest(
|
||||
`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(mediaId)}/people`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function uploadPersonAvatar(treeId, personId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -26,6 +26,7 @@ function formatEntityType(entityType) {
|
||||
if (entityType === 'annotation') return t('annotation')
|
||||
if (entityType === 'media') return t('souvenir')
|
||||
if (entityType === 'life_event') return t('moment')
|
||||
if (entityType === 'media_people') return t('personnes sur un souvenir')
|
||||
return t('élément')
|
||||
}
|
||||
|
||||
@@ -51,6 +52,17 @@ function getMediaPreview(change, treeId) {
|
||||
return { type: data.type, label, caption, url, isYoutube }
|
||||
}
|
||||
|
||||
// Personnes identifiées sur un souvenir : la photo, qui y était, qui est proposé
|
||||
function getMediaPeoplePreview(change, treeId) {
|
||||
const after = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
|
||||
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : {}
|
||||
const names = (list) => (Array.isArray(list) ? list : []).map((tag) => nameOfPersonRef(tag?.personId)).filter(Boolean)
|
||||
const url = change.entityId && treeId
|
||||
? buildMediaUrl(`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(change.entityId)}`, API_BASE_URL)
|
||||
: null
|
||||
return { url, before: names(before.people), after: names(after.people) }
|
||||
}
|
||||
|
||||
// Moment proposé par la famille : quand, où, et ce qu'elle en dit. Ses fichiers sont dans « Souvenirs »
|
||||
function getMomentPreview(change) {
|
||||
const data = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
|
||||
@@ -138,6 +150,10 @@ function getPersonDisplayName(change, sessionChanges = []) {
|
||||
const data = change.afterJson || {}
|
||||
return data.title || t('Moment')
|
||||
}
|
||||
if (change.entityType === 'media_people') {
|
||||
const owner = nameOfPersonRef(change.afterJson?.ownerPersonId)
|
||||
return owner ? t('Photo de {name}', { name: owner }) : t('Personnes sur un souvenir')
|
||||
}
|
||||
// Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base)
|
||||
const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
|
||||
const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null
|
||||
@@ -510,8 +526,8 @@ const ContributionPanel = ({
|
||||
{/* ── Cartes de changement (collapsibles) ── */}
|
||||
{!isCollapsed && (() => {
|
||||
// Grouper les changements par entityType
|
||||
const groupOrder = ['life_event', 'media', 'person', 'union', 'parent_child_link', 'annotation']
|
||||
const groupLabels = { life_event: t('Moments'), media: t('Souvenirs'), person: t('Personnes'), union: t('Unions'), parent_child_link: t('Liens'), annotation: t('Annotations') }
|
||||
const groupOrder = ['life_event', 'media', 'media_people', 'person', 'union', 'parent_child_link', 'annotation']
|
||||
const groupLabels = { life_event: t('Moments'), media: t('Souvenirs'), media_people: t('Sur les photos'), person: t('Personnes'), union: t('Unions'), parent_child_link: t('Liens'), annotation: t('Annotations') }
|
||||
const groupMap = {}
|
||||
for (const ch of (session.changes || [])) {
|
||||
const key = ch.entityType || 'other'
|
||||
@@ -533,6 +549,7 @@ const ContributionPanel = ({
|
||||
const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change, treeId) : null
|
||||
const mediaPreview = change.entityType === 'media' ? getMediaPreview(change, treeId) : null
|
||||
const momentPreview = change.entityType === 'life_event' ? getMomentPreview(change) : null
|
||||
const peoplePreview = change.entityType === 'media_people' ? getMediaPeoplePreview(change, treeId) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -603,6 +620,22 @@ const ContributionPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{peoplePreview && (
|
||||
<div className="contrib-media-preview">
|
||||
{peoplePreview.url && (
|
||||
<a href={peoplePreview.url} target="_blank" rel="noreferrer" className="contrib-media-photo">
|
||||
<img src={peoplePreview.url} alt={t('Photo du souvenir')} onError={(e) => { e.currentTarget.style.display = 'none' }} />
|
||||
</a>
|
||||
)}
|
||||
<p className="contrib-media-caption">
|
||||
{peoplePreview.after.length ? t('Sur la photo, {names}', { names: peoplePreview.after.join(', ') }) : t('Plus personne sur la photo')}
|
||||
</p>
|
||||
{peoplePreview.before.length > 0 && (
|
||||
<p className="contrib-media-caption">{t('Avant, {names}', { names: peoplePreview.before.join(', ') })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{momentPreview && (
|
||||
<div className="contrib-moment-preview">
|
||||
{momentPreview.when && <span>{momentPreview.when}</span>}
|
||||
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Search, UserPlus, X } from 'lucide-react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel, fullNameLabel } from '../utils/personName.js'
|
||||
import './MediaPeople.css'
|
||||
|
||||
const MAX_RESULTS = 6
|
||||
|
||||
const normalize = (value) => String(value || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
|
||||
function PersonAvatar({ person }) {
|
||||
return person?.photo
|
||||
? <img className="mp-avatar" src={person.photo} alt="" />
|
||||
: <span className="mp-avatar mp-avatar--initial" aria-hidden="true">{person?.firstName?.[0] || '?'}</span>
|
||||
}
|
||||
|
||||
// Points posés sur la photo : un rond par visage, le prénom au survol ou au focus. En identification, le point de
|
||||
// la personne choisie est mis en avant.
|
||||
export function MediaPeopleMarkers({ people, getPerson, activeId = null, onOpenPerson }) {
|
||||
return people.filter((tag) => tag.x != null && tag.y != null).map((tag) => {
|
||||
const person = getPerson(tag.personId)
|
||||
if (!person) return null
|
||||
const name = firstNameLabel(person)
|
||||
return (
|
||||
<button
|
||||
key={tag.personId}
|
||||
type="button"
|
||||
className={`mp-marker${activeId === tag.personId ? ' is-active' : ''}`}
|
||||
style={{ left: `${tag.x * 100}%`, top: `${tag.y * 100}%` }}
|
||||
onClick={(event) => { event.stopPropagation(); onOpenPerson?.(tag.personId) }}
|
||||
aria-label={t('Voir la fiche de {name}', { name })}
|
||||
>
|
||||
<span className="mp-marker-name">{name}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// « Sur la photo » : les personnes identifiées, chacune ouvre sa fiche. En identification : retirer, ajouter,
|
||||
// choisir la personne dont on place le point.
|
||||
export function MediaPeopleList({
|
||||
people,
|
||||
getPerson,
|
||||
allPersons = [],
|
||||
editing = false,
|
||||
canPlace = false,
|
||||
activeId = null,
|
||||
onActivate,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onOpenPerson,
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const taken = useMemo(() => new Set(people.map((tag) => tag.personId)), [people])
|
||||
const results = useMemo(() => {
|
||||
const q = normalize(query.trim())
|
||||
if (!q) return []
|
||||
return allPersons
|
||||
.filter((person) => !taken.has(person.id))
|
||||
.filter((person) => normalize(`${person.firstName || ''} ${person.lastName || ''} ${person.nickname || ''}`).includes(q))
|
||||
.slice(0, MAX_RESULTS)
|
||||
}, [allPersons, query, taken])
|
||||
|
||||
const known = people.map((tag) => ({ tag, person: getPerson(tag.personId) })).filter((entry) => entry.person)
|
||||
|
||||
if (!editing) {
|
||||
if (!known.length) return null
|
||||
return (
|
||||
<div className="mp-list" aria-label={t('Sur la photo')}>
|
||||
<span className="mp-list-label">{t('Sur la photo')}</span>
|
||||
{known.map(({ person }) => (
|
||||
<button key={person.id} type="button" className="mp-chip" onClick={() => onOpenPerson?.(person.id)}>
|
||||
<PersonAvatar person={person} />
|
||||
<span>{firstNameLabel(person)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mp-edit">
|
||||
{known.length > 0 && (
|
||||
<ul className="mp-edit-list">
|
||||
{known.map(({ tag, person }) => {
|
||||
const active = activeId === person.id
|
||||
const placed = tag.x != null
|
||||
return (
|
||||
<li key={person.id} className={`mp-edit-row${active ? ' is-active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-edit-pick"
|
||||
onClick={() => onActivate?.(active ? null : person.id)}
|
||||
aria-pressed={canPlace ? active : undefined}
|
||||
disabled={!canPlace}
|
||||
>
|
||||
<PersonAvatar person={person} />
|
||||
<span className="mp-edit-name">{fullNameLabel(person)}</span>
|
||||
{canPlace && (
|
||||
<span className="mp-edit-state">
|
||||
{active ? t('Touchez son visage') : placed ? <><Check size={13} strokeWidth={2.4} aria-hidden="true" /> {t('Placé')}</> : t('Placer')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon mp-edit-remove" onClick={() => onRemove?.(person.id)} aria-label={t('Retirer {name}', { name: firstNameLabel(person) })}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mp-search">
|
||||
<Search size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
className="mp-search-input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('Qui est sur la photo ?')}
|
||||
aria-label={t('Chercher une personne de l’arbre')}
|
||||
/>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<ul className="mp-results" role="listbox" aria-label={t('Personnes trouvées')}>
|
||||
{results.map((person) => (
|
||||
<li key={person.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
className="mp-result"
|
||||
onClick={() => { onAdd?.(person.id); setQuery('') }}
|
||||
>
|
||||
<UserPlus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<PersonAvatar person={person} />
|
||||
<span>{fullNameLabel(person)}</span>
|
||||
{person.birthYear && <span className="mp-result-year">{person.birthYear}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Pencil, X } from 'lucide-react'
|
||||
import { Pencil, UserPlus, X } from 'lucide-react'
|
||||
import { MediaPeopleList, MediaPeopleMarkers } from './MediaPeople.jsx'
|
||||
import {
|
||||
buildOpenStreetMapEmbedUrl,
|
||||
buildOpenStreetMapUrl,
|
||||
@@ -23,8 +24,27 @@ const findLinkedEventId = (events, mediaId) => {
|
||||
return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null
|
||||
}
|
||||
|
||||
const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => {
|
||||
const MediaViewer = ({
|
||||
media,
|
||||
person,
|
||||
onClose,
|
||||
canEdit = false,
|
||||
onLoadLifeEvents,
|
||||
onSave,
|
||||
// Personnes identifiées : canTag ouvre l'identification (admin en direct, famille en proposition)
|
||||
getPerson = () => null,
|
||||
allPersons = [],
|
||||
canTag = false,
|
||||
onSavePeople,
|
||||
onOpenPerson,
|
||||
}) => {
|
||||
const [mapState, setMapState] = useState({ loading: false, error: '', data: null })
|
||||
const [tagging, setTagging] = useState(false)
|
||||
const [draftPeople, setDraftPeople] = useState([])
|
||||
const [activeTagId, setActiveTagId] = useState(null)
|
||||
const [savingPeople, setSavingPeople] = useState(false)
|
||||
const [peopleError, setPeopleError] = useState('')
|
||||
const [peopleNotice, setPeopleNotice] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [lifeEvents, setLifeEvents] = useState([])
|
||||
const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null })
|
||||
@@ -39,6 +59,58 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
|
||||
}
|
||||
|
||||
const isCitation = media?.type === 'citation'
|
||||
const savedPeople = Array.isArray(media?.people) ? media.people : []
|
||||
const shownPeople = tagging ? draftPeople : savedPeople
|
||||
|
||||
const startTagging = () => {
|
||||
setPeopleError('')
|
||||
setPeopleNotice('')
|
||||
setDraftPeople(savedPeople.map((tag) => ({ ...tag })))
|
||||
setActiveTagId(null)
|
||||
setTagging(true)
|
||||
}
|
||||
|
||||
const cancelTagging = () => {
|
||||
setTagging(false)
|
||||
setActiveTagId(null)
|
||||
setPeopleError('')
|
||||
}
|
||||
|
||||
// Ajoutée, la personne devient celle dont on place le point (photo seulement)
|
||||
const addTag = (personId) => {
|
||||
setDraftPeople((current) => [...current, { personId, x: null, y: null }])
|
||||
setActiveTagId(media?.type === 'photo' ? personId : null)
|
||||
}
|
||||
|
||||
const removeTag = (personId) => {
|
||||
setDraftPeople((current) => current.filter((tag) => tag.personId !== personId))
|
||||
setActiveTagId((current) => (current === personId ? null : current))
|
||||
}
|
||||
|
||||
const placeActiveTag = (event) => {
|
||||
if (!tagging || !activeTagId) return
|
||||
const box = event.currentTarget.getBoundingClientRect()
|
||||
const clamp = (value) => Math.min(1, Math.max(0, value))
|
||||
const x = Number(clamp((event.clientX - box.left) / box.width).toFixed(4))
|
||||
const y = Number(clamp((event.clientY - box.top) / box.height).toFixed(4))
|
||||
setDraftPeople((current) => current.map((tag) => (tag.personId === activeTagId ? { ...tag, x, y } : tag)))
|
||||
setActiveTagId(null)
|
||||
}
|
||||
|
||||
const savePeople = async () => {
|
||||
setSavingPeople(true)
|
||||
setPeopleError('')
|
||||
try {
|
||||
const result = await onSavePeople(draftPeople)
|
||||
setTagging(false)
|
||||
setActiveTagId(null)
|
||||
if (result?.status === 'pending') setPeopleNotice(t('Merci. La personne qui gère l’arbre va relire votre proposition.'))
|
||||
} catch (error) {
|
||||
setPeopleError(getAccountErrorMessage(error))
|
||||
} finally {
|
||||
setSavingPeople(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = async () => {
|
||||
setSaveError('')
|
||||
@@ -234,9 +306,18 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
|
||||
)
|
||||
}
|
||||
|
||||
// La boîte épouse l'image : les points (x, y en proportion) tombent pile sur les visages
|
||||
return (
|
||||
<div className="media-photo-frame">
|
||||
<img src={mediaUrl} alt={media?.label || t('Photo')} />
|
||||
<div className={`mv-photo-box${tagging ? ' is-tagging' : ''}${activeTagId ? ' is-placing' : ''}`} onClick={placeActiveTag}>
|
||||
<img src={mediaUrl} alt={media?.label || t('Photo')} />
|
||||
<MediaPeopleMarkers
|
||||
people={shownPeople}
|
||||
getPerson={getPerson}
|
||||
activeId={activeTagId}
|
||||
onOpenPerson={tagging ? (id) => setActiveTagId(id) : onOpenPerson}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -534,6 +615,48 @@ const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents
|
||||
</span>
|
||||
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
|
||||
</div>
|
||||
{(mediaType === 'photo' || mediaType === 'video') && !editing && (
|
||||
tagging ? (
|
||||
<div className="mv-people-edit">
|
||||
<p className="mv-people-hint">
|
||||
{mediaType === 'photo'
|
||||
? (activeTagId ? t('Touchez son visage sur la photo.') : t('Ajoutez les personnes, puis touchez leur visage sur la photo.'))
|
||||
: t('Ajoutez les personnes de la vidéo.')}
|
||||
</p>
|
||||
<MediaPeopleList
|
||||
people={draftPeople}
|
||||
getPerson={getPerson}
|
||||
allPersons={allPersons}
|
||||
editing
|
||||
canPlace={mediaType === 'photo'}
|
||||
activeId={activeTagId}
|
||||
onActivate={setActiveTagId}
|
||||
onAdd={addTag}
|
||||
onRemove={removeTag}
|
||||
/>
|
||||
{peopleError && <p className="mv-edit-error" role="alert">{peopleError}</p>}
|
||||
<div className="mv-edit-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={cancelTagging} disabled={savingPeople}>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--primary" onClick={savePeople} disabled={savingPeople}>
|
||||
{t('Enregistrer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mv-people">
|
||||
<MediaPeopleList people={savedPeople} getPerson={getPerson} onOpenPerson={onOpenPerson} />
|
||||
{canTag && onSavePeople && (
|
||||
<button type="button" className="pz-btn pz-btn--soft pz-btn--sm mv-people-btn" onClick={startTagging}>
|
||||
<UserPlus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{savedPeople.length ? t('Modifier les personnes') : mediaType === 'video' ? t('Qui est sur la vidéo ?') : t('Qui est sur la photo ?')}
|
||||
</button>
|
||||
)}
|
||||
{peopleNotice && <p className="mv-people-notice" role="status">{peopleNotice}</p>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{mediaSource && !editing && (
|
||||
<p className="mv-source">
|
||||
{t('Source')}{' '}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
|
||||
import { fetchPersonLife } from '../api/treeApi.js'
|
||||
import LifeTimeline from './LifeTimeline.jsx'
|
||||
import MomentSheet from './MomentSheet.jsx'
|
||||
@@ -80,7 +80,7 @@ const PersonCard = ({
|
||||
saveOnLeaveRef = null,
|
||||
}) => {
|
||||
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
|
||||
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
|
||||
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || getPersonTaggedMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
|
||||
const [lifeState, setLifeState] = useState({ personId: null, data: null, loading: false, failed: false })
|
||||
|
||||
const lifeRequestedFor = useRef(null)
|
||||
@@ -461,6 +461,8 @@ const PersonCard = ({
|
||||
return childFiliations[0]
|
||||
})()
|
||||
const personMedias = getPersonMedias(person.id)
|
||||
// Souvenirs des autres où la personne est identifiée : après les siens, ouverts au nom de leur propriétaire
|
||||
const taggedMedias = getPersonTaggedMedias(person.id).filter((m) => m.type === 'photo' || m.type === 'video')
|
||||
// Filiation d'adoption entre un enfant et un parent (lien direct ou via une union)
|
||||
const parentageAdopted = (childId, parentId) => filiations.some((f) => (
|
||||
String(f.childId) === String(childId)
|
||||
@@ -813,7 +815,7 @@ const PersonCard = ({
|
||||
{/* ── Souvenirs ── */}
|
||||
{tab === 'medias' && (
|
||||
<div className="pc-souvenirs">
|
||||
{personMedias.length === 0 && (
|
||||
{personMedias.length === 0 && taggedMedias.length === 0 && (
|
||||
<div className="pc-empty">
|
||||
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: firstNameLabel(person) })}</p>
|
||||
<p className="pz-small">{canManageMedia ? t('Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.') : t('Une photo, une anecdote, sa voix.')}</p>
|
||||
@@ -859,6 +861,31 @@ const PersonCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taggedMedias.length > 0 && (
|
||||
<section className="pc-tagged" aria-label={t('Avec {name}', { name: firstNameLabel(person) })}>
|
||||
<h3 className="pc-tagged-title">{t('Avec {name}', { name: firstNameLabel(person) })}</h3>
|
||||
<div className="pc-grid">
|
||||
{taggedMedias.map((media) => {
|
||||
const owner = getPersonById(media.personId)
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`pc-tile pc-tile--${media.type}`}
|
||||
onClick={() => owner && onMediaSelect?.(media, owner)}
|
||||
title={media.label || TYPE_LABELS[media.type]}
|
||||
>
|
||||
{media.type === 'photo' && media.url
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" />
|
||||
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
|
||||
{media.label && <span className="pc-tile-label">{media.label}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{otherMedias.length > 0 && (
|
||||
<div className="pc-list">
|
||||
{otherMedias.map((media) => (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
Reference in new issue
Block a user