From 63f5b63f1228a647ce2becab7f2af5aba4804380 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:28:34 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20recadrage=20du=20portrait,=20photo?= =?UTF-8?q?=20enti=C3=A8re=20conserv=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cadrage (centre et zoom) est stocké dans Person.avatarCrop (JSON, nullable), l'original reste sur disque et le cadrage s'applique à l'affichage (canevas et fiche). Route PUT /trees/:id/persons/:personId/avatar/crop, réservée aux administrateurs. La suppression et le nouvel envoi remettent le cadrage à zéro. Migration additive (colonne nullable, sans défaut), testée sur base vide et sur base avec données. Co-Authored-By: Claude Haiku 5.5 --- .../20261018_002_avatar_crop/migration.sql | 3 + backend/prisma/schema.prisma | 4 + backend/src/lib/avatar-crop.ts | 34 +++ backend/src/lib/entity-deletion.ts | 4 +- backend/src/routes/immich.ts | 5 + backend/src/routes/media.ts | 69 +++++ backend/src/utils/graph-payload.ts | 3 + backend/test/avatar-crop.test.ts | 217 +++++++++++++++ frontend/src/App.jsx | 27 ++ frontend/src/api/treeApi.js | 13 + frontend/src/components/PersonCard.css | 8 +- frontend/src/components/PersonCard.jsx | 109 ++++++-- frontend/src/components/PortraitCropModal.css | 57 ++++ frontend/src/components/PortraitCropModal.jsx | 253 ++++++++++++++++++ frontend/src/components/galaxy/renderers.js | 23 +- frontend/src/data/mockData.js | 1 + frontend/src/i18n/en/fiche.js | 13 + frontend/src/utils/graphAdapter.js | 1 + frontend/src/utils/portraitCrop.js | 87 ++++++ frontend/src/utils/portraitCrop.test.js | 88 ++++++ 20 files changed, 977 insertions(+), 42 deletions(-) create mode 100644 backend/prisma/migrations/20261018_002_avatar_crop/migration.sql create mode 100644 backend/src/lib/avatar-crop.ts create mode 100644 backend/test/avatar-crop.test.ts create mode 100644 frontend/src/components/PortraitCropModal.css create mode 100644 frontend/src/components/PortraitCropModal.jsx create mode 100644 frontend/src/utils/portraitCrop.js create mode 100644 frontend/src/utils/portraitCrop.test.js diff --git a/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql b/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql new file mode 100644 index 0000000..5882f11 --- /dev/null +++ b/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql @@ -0,0 +1,3 @@ +-- Cadrage du portrait (feat/recadrage-portrait). Migration additive : colonne nullable, sans défaut. +-- AlterTable +ALTER TABLE "persons" ADD COLUMN "avatarCrop" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d01eb53..bcc3957 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -230,6 +230,10 @@ model Person { nationality String? avatarPath String? avatarMimeType String? + // Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) : + // { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière + // recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1). + avatarCrop Json? sex String? notes String? createdBy String? diff --git a/backend/src/lib/avatar-crop.ts b/backend/src/lib/avatar-crop.ts new file mode 100644 index 0000000..c93aee0 --- /dev/null +++ b/backend/src/lib/avatar-crop.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +// Cadrage d'un portrait : centre (proportions de l'image) et zoom. Zoom 1 = l'image entière recouvre le cadre. +// Les bornes qui empêchent le vide sont appliquées à l'affichage (frontend, portraitCrop.js), le serveur +// vérifie seulement la forme et les plages. +export type AvatarCrop = { x: number; y: number; zoom: number } + +export const AVATAR_CROP_MAX_ZOOM = 4 + +export const avatarCropInputSchema = z.object({ + crop: z + .object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM), + }) + .nullable(), +}) + +const round4 = (value: number) => Math.round(value * 10000) / 10000 + +// Forme à écrire en base. Le cadrage par défaut se stocke comme absence de cadrage (null). +export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null { + if (!crop) return null + const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) } + if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null + return normalized +} + +// Lecture de la colonne JSON : tout ce qui ne respecte pas la forme est ignoré (portrait affiché tel quel). +export function readAvatarCrop(value: unknown): AvatarCrop | null { + const parsed = avatarCropInputSchema.shape.crop.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/backend/src/lib/entity-deletion.ts b/backend/src/lib/entity-deletion.ts index 2078dee..e9b688c 100644 --- a/backend/src/lib/entity-deletion.ts +++ b/backend/src/lib/entity-deletion.ts @@ -1,4 +1,4 @@ -import type { Prisma, PrismaClient } from '@prisma/client' +import { Prisma, type PrismaClient } from '@prisma/client' type Db = PrismaClient | Prisma.TransactionClient @@ -32,7 +32,7 @@ export async function softDeletePersonCascade( await db.person.updateMany({ where: { id: personId, treeId, deletedAt: null }, - data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, immichPersonId: null }, + data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null }, }) const unions = await db.union.findMany({ diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 9e11849..116fd51 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client' import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' import path from 'node:path' import { randomUUID } from 'node:crypto' @@ -472,6 +473,8 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: relativePath, avatarMimeType: prepared.mimeType, + // Nouveau portrait, nouveau cadrage à faire + avatarCrop: Prisma.DbNull, immichPersonId, updatedBy: request.actor.userId, }, @@ -551,10 +554,12 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { updatedBy: string avatarPath?: null avatarMimeType?: null + avatarCrop?: typeof Prisma.DbNull } = { immichPersonId: null, updatedBy: request.actor.userId } if (avatarFromImmich) { updateData.avatarPath = null updateData.avatarMimeType = null + updateData.avatarCrop = Prisma.DbNull } // Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour. diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 0d917b4..3aa4ec1 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' import path from 'node:path' +import { Prisma } from '@prisma/client' import type { FastifyPluginAsync, FastifyRequest } from 'fastify' import { z } from 'zod' @@ -10,6 +11,7 @@ import { applyMediaResponseHeaders, extensionForMime, InvalidMediaError, + isImmichAvatarPath, isMimeAllowedForType, removeStoredFile, sanitizeImage, @@ -17,6 +19,7 @@ import { sniffMimeType, type BinaryMediaType, } from '../lib/media-files.js' +import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js' import { syncPersonMediaOrder } from '../lib/media-order.js' import { resolveTreeMediaDirectory } from '../lib/tree-purge.js' import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' @@ -593,6 +596,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: relativePath, avatarMimeType: prepared.mimeType, + // Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci + avatarCrop: Prisma.DbNull, updatedBy: request.actor.userId, // Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association // survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne @@ -648,6 +653,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: null, avatarMimeType: null, + avatarCrop: Prisma.DbNull, // Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait. immichPersonId: null, updatedBy: request.actor.userId, @@ -674,6 +680,69 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.send({ ok: true }) }) + // Cadrage du portrait : l'original reste en place, seul le cadrage est enregistré. Même droit que changer la photo. + app.put('/trees/:id/persons/:personId/avatar/crop', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = avatarCropInputSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const person = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { avatarPath: true }, + }) + + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + if (!person.avatarPath) { + return reply.code(404).send({ error: 'avatar_not_found' }) + } + + // Hors périmètre pour l'instant : les portraits venus d'Immich ne se recadrent pas + if (isImmichAvatarPath(person.avatarPath)) { + return reply.code(409).send({ error: 'avatar_from_immich' }) + } + + const avatarCrop = toStoredAvatarCrop(payload.data.crop) + + await app.prisma.person.updateMany({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + data: { + avatarCrop: avatarCrop ?? Prisma.DbNull, + updatedBy: request.actor.userId, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'person_avatar_cropped', + entityType: 'person', + entityId: params.data.personId, + }, + }) + + return reply.send({ ok: true, avatarCrop: readAvatarCrop(avatarCrop) }) + }) + app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 86c83bb..5ae6e46 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -1,6 +1,7 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client' import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' +import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js' import { isImmichAvatarPath } from '../lib/media-files.js' export type GraphPayload = { @@ -21,6 +22,7 @@ export type GraphPayload = { sex: string | null notes: string | null avatarFromImmich: boolean + avatarCrop: AvatarCrop | null }> unions: Array<{ id: string @@ -97,6 +99,7 @@ export function buildGraphPayload( sex: person.sex, notes: person.notes, avatarFromImmich: isImmichAvatarPath(person.avatarPath), + avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null, })), unions: unions.map((union) => ({ id: union.id, diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts new file mode 100644 index 0000000..0dfb78a --- /dev/null +++ b/backend/test/avatar-crop.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { Prisma } from '@prisma/client' + +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + count: vi.fn(), + }, + person: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + treeMembership: { + findUnique: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance +let storageRoot: string + +const TREE = 'tree-1' +const PERSON = 'person-1' +const url = `/trees/${TREE}/persons/${PERSON}/avatar/crop` + +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' }, + ) +} + +function asAdmin(role: 'owner' | 'admin' | 'member' = 'owner') { + prismaMock.treeMembership.findUnique.mockResolvedValue({ role, tree: { deletedAt: null } }) +} + +beforeAll(async () => { + 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' + + storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-crop-')) + process.env.MEDIA_STORAGE_PATH = storageRoot + + const module = await import('../src/app.js') + createApp = module.createApp +}) + +afterAll(async () => { + await rm(storageRoot, { recursive: true, force: true }) +}) + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.user.count.mockResolvedValue(1) + prismaMock.person.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.person.findFirst.mockResolvedValue({ avatarPath: `${TREE}/${PERSON}/avatar-a.jpg` }) + asAdmin('owner') +}) + +describe('PUT /trees/:id/persons/:personId/avatar/crop', () => { + it('refuse sans session', async () => { + const app = createApp() + const response = await app.inject({ method: 'PUT', url, payload: { crop: null } }) + expect(response.statusCode).toBe(401) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('réserve le cadrage aux administrateurs de l arbre, comme le changement de photo', async () => { + const app = createApp() + const token = await createUserToken(app) + asAdmin('member') + + const response = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } }, + }) + + expect(response.statusCode).toBe(403) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('refuse un cadrage hors plage ou mal formé', async () => { + const app = createApp() + const token = await createUserToken(app) + const headers = { authorization: `Bearer ${token}` } + + const tooZoomed = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5, y: 0.5, zoom: 5 } } }) + const outside = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 1.2, y: 0.5, zoom: 1 } } }) + const missing = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5 } } }) + const noBody = await app.inject({ method: 'PUT', url, headers, payload: {} }) + + expect(tooZoomed.statusCode).toBe(400) + expect(outside.statusCode).toBe(400) + expect(missing.statusCode).toBe(400) + expect(noBody.statusCode).toBe(400) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('enregistre le cadrage arrondi et audite le changement', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.123456, y: 0.7, zoom: 1.8 } }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarCrop: { x: 0.1235, y: 0.7, zoom: 1.8 } }) + + const call = prismaMock.person.updateMany.mock.calls[0][0] + expect(call.where).toEqual({ id: PERSON, treeId: TREE, deletedAt: null }) + expect(call.data.avatarCrop).toEqual({ x: 0.1235, y: 0.7, zoom: 1.8 }) + // La photo elle-même n'est jamais touchée + expect(call.data).not.toHaveProperty('avatarPath') + expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'person_avatar_cropped', entityId: PERSON, treeId: TREE }), + }) + }) + + it('remet le cadrage par défaut à zéro (null) plutôt que de le stocker', async () => { + const app = createApp() + const token = await createUserToken(app) + + const reset = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: null }, + }) + expect(reset.statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull) + + prismaMock.person.updateMany.mockClear() + await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.5, y: 0.5, zoom: 1 } }, + }) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull) + }) + + it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => { + const app = createApp() + const token = await createUserToken(app) + const headers = { authorization: `Bearer ${token}` } + + prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: null }) + const noAvatar = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } }) + expect(noAvatar.statusCode).toBe(404) + + prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: `${TREE}/${PERSON}/immich-abc.jpg` }) + const fromImmich = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } }) + expect(fromImmich.statusCode).toBe(409) + expect(fromImmich.json()).toEqual({ error: 'avatar_from_immich' }) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) +}) + +describe('DELETE avatar : le cadrage disparaît avec la photo', () => { + it('remet avatarCrop à null en même temps que le portrait', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'DELETE', + url: `/trees/${TREE}/persons/${PERSON}/avatar`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const data = prismaMock.person.updateMany.mock.calls[0][0].data + expect(data.avatarPath).toBeNull() + expect(data.avatarCrop).toBe(Prisma.DbNull) + }) +}) + +describe('lecture du cadrage stocké', () => { + it('ignore toute valeur mal formée au lieu de déformer le portrait', async () => { + const { readAvatarCrop, toStoredAvatarCrop } = await import('../src/lib/avatar-crop.js') + + expect(readAvatarCrop(null)).toBeNull() + expect(readAvatarCrop('oui')).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3 })).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 9 })).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 2 })).toEqual({ x: 0.2, y: 0.3, zoom: 2 }) + + expect(toStoredAvatarCrop({ x: 0.5, y: 0.5, zoom: 1 })).toBeNull() + expect(toStoredAvatarCrop(null)).toBeNull() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index de7d4be..472b546 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -42,6 +42,7 @@ import { fetchSharePassword, rememberSharePassword, resolveTreeBySlug, + saveAvatarCrop, uploadPersonAvatar, updatePerson, uploadAnnotationPhoto, @@ -1602,8 +1603,33 @@ function App() { trackAppEvent('portrait_added', { via: 'card' }) await refreshTreeAndKeepSelection(personId, { relayout: true }) setAvatarActionState({ loading: false, error: '' }) + // Aperçu de la photo envoyée, pour la fenêtre de recadrage qui s'ouvre tout de suite + return { previewUrl: URL.createObjectURL(prepared) } } catch (error) { setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) }) + return null + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + + // Recadrage du portrait enregistré : true si c'est fait, la fenêtre se ferme alors + const handlePersonAvatarCrop = useCallback(async (personId, crop) => { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return false + + setAvatarActionState({ loading: true, error: '' }) + try { + await saveAvatarCrop(tree.treeContext.treeId, personId, crop, accessToken) + await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) + setAvatarActionState({ loading: false, error: '' }) + return true + } catch (error) { + setAvatarActionState({ + loading: false, + error: error?.payload?.error === 'avatar_from_immich' + ? t('Les photos venues d’Immich ne se recadrent pas pour l’instant.') + : getAccountErrorMessage(error), + }) + return false } }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) @@ -3028,6 +3054,7 @@ function App() { onManageMedia={media.handleOpenMediaManager} onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined} onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined} + onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined} treeId={tree.treeContext.treeId || null} immichToken={tree.treeContext.accessToken || auth.userAuth.token} onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined} diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 8ad1ad0..36e770a 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -453,6 +453,19 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) { return data } +// Cadrage du portrait ({ x, y, zoom }) ou null pour le cadrage par défaut +export async function saveAvatarCrop(treeId, personId, crop, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar/crop`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ crop }), + }) + + return data +} + export async function deletePersonAvatar(treeId, personId, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, { method: 'DELETE', diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index a4c3709..7157327 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -38,7 +38,7 @@ .pc-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; align-items: start; padding-right: 36px; } .pc-photo-wrap { position: relative; display: grid; gap: 8px; justify-items: center; flex: none; } .pc-photo { - width: 84px; height: 84px; border-radius: 24%; overflow: hidden; + position: relative; width: 84px; height: 84px; border-radius: 24%; overflow: hidden; background: var(--pz-a100); box-shadow: 0 0 0 1px rgba(42, 38, 34, .06), var(--pz-shadow); display: grid; place-items: center; } @@ -74,12 +74,6 @@ .pc-photo-badge:active:not(:disabled) { transform: scale(.94); } .pc-photo-badge:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } .pc-photo-badge:disabled { opacity: .5; cursor: default; } -.pc-photo-btn { - border: 0; background: none; padding: 2px 4px; cursor: pointer; - font: 500 12px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 2px; -} -.pc-photo-btn--danger { color: var(--pz-danger); justify-self: center; } -.pc-photo-btn:disabled { opacity: .5; cursor: default; } .pc-id { display: grid; gap: 6px; min-width: 0; padding-top: 4px; } .pc-name { margin: 0; font: 400 28px/1.08 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); text-wrap: balance; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 80a9b39..8c11c94 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 { createPortal } from 'react-dom' -import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react' -import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, filiations, persons, unions } from '../data/mockData' +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 { fetchPersonLife } from '../api/treeApi.js' import LifeTimeline from './LifeTimeline.jsx' import MomentSheet from './MomentSheet.jsx' @@ -10,6 +10,8 @@ import GeoMediaThumbnail from './GeoMediaThumbnail' import ImmichFacePanel from './ImmichFacePanel.jsx' import './PersonCard.css' import PzBusy from './PzBusy.jsx' +import PortraitCropModal from './PortraitCropModal.jsx' +import { imagePlacement } from '../utils/portraitCrop.js' import { t, dateLocale } from '../i18n/index.js' import { formatPlace } from '../utils/place.js' import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js' @@ -45,6 +47,7 @@ const PersonCard = ({ onManageMedia, onAvatarUpload, onAvatarDelete, + onAvatarCrop, treeId = null, immichToken = null, onImmichChange, @@ -111,6 +114,11 @@ const PersonCard = ({ const [showSourceMenu, setShowSourceMenu] = useState(false) const [sourceMenuPos, setSourceMenuPos] = useState({ top: 0, left: 0 }) const sourceBtnRef = useRef(null) + // Menu du badge appareil photo (position, ou null fermé) et fenêtre de recadrage ({ src, mode, ownsUrl }, ou null) + const [photoMenuPos, setPhotoMenuPos] = useState(null) + const [cropState, setCropState] = useState(null) + // Taille naturelle de la photo affichée dans la fiche, pour placer le portrait recadré + const [photoSize, setPhotoSize] = useState(null) // Inline relation search state const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null @@ -132,6 +140,9 @@ const PersonCard = ({ setShowSourceMenu(false) setMomentSheet(null) setLifeNotice('') + setPhotoMenuPos(null) + setCropState(null) + setPhotoSize(null) }, [person.id]) // Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se @@ -155,6 +166,39 @@ const PersonCard = ({ return () => window.removeEventListener('keydown', onKeyDown) }, [showSourceMenu]) + // Escape ferme le menu du badge + useEffect(() => { + if (!photoMenuPos) return undefined + const onKeyDown = (event) => { if (event.key === 'Escape') setPhotoMenuPos(null) } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [photoMenuPos]) + + const openPhotoMenu = () => { + if (avatarActionLoading) return + const rect = sourceBtnRef.current?.getBoundingClientRect() + if (rect) { + setPhotoMenuPos({ + top: Math.round(rect.bottom) + 6, + left: Math.round(Math.min(rect.left, window.innerWidth - 232)), + }) + } + } + + // Fermer la fenêtre de recadrage ; l'aperçu envoyé est libéré + const closeCrop = () => { + if (cropState?.ownsUrl) URL.revokeObjectURL(cropState.src) + setCropState(null) + } + + // Placement du portrait dans la fiche (carrée), comme le canevas + const photoPlacement = photoSize ? imagePlacement(photoSize.w, photoSize.h, 1, person.avatarCrop) : null + const photoStyle = photoPlacement + ? { position: 'absolute', maxWidth: 'none', left: `${photoPlacement.left}%`, top: `${photoPlacement.top}%`, width: `${photoPlacement.width}%`, height: `${photoPlacement.height}%` } + : undefined + // Même forme de cadre que dans l'arbre (renderers.js) + const treeFrameType = getPersonById(person.id)?.frameType || person.frameType + const immichUsable = isAdmin && Boolean(immichConfigured || person.avatarFromImmich) const openAvatarPicker = () => { if (avatarActionLoading) return @@ -237,7 +281,9 @@ const PersonCard = ({ const file = event.target.files?.[0] event.target.value = '' if (!file || !onAvatarUpload) return - await onAvatarUpload(person.id, file) + const result = await onAvatarUpload(person.id, file) + // Juste après l'envoi, la fenêtre de recadrage s'ouvre (« Garder tel quel » laisse la photo entière) + if (result?.previewUrl) setCropState({ src: result.previewUrl, mode: 'new', ownsUrl: true }) } // Relation actions @@ -512,7 +558,7 @@ const PersonCard = ({ ) : (
{person.photo - ? {fullNameLabel(person)} + ? {fullNameLabel(person)} setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} /> : {initials}}
)} @@ -521,7 +567,7 @@ const PersonCard = ({ ref={sourceBtnRef} type="button" className="pc-photo-badge" - onClick={openAvatarPicker} + onClick={openPhotoMenu} disabled={avatarActionLoading} aria-label={t('Changer la photo')} title={t('Changer la photo')} @@ -531,16 +577,6 @@ const PersonCard = ({