diff --git a/backend/src/lib/immich.ts b/backend/src/lib/immich.ts index 8645bde..9c9bb4f 100644 --- a/backend/src/lib/immich.ts +++ b/backend/src/lib/immich.ts @@ -4,6 +4,7 @@ import type { PrismaClient } from '@prisma/client' import { env } from '../config/env.js' import { decryptImmichApiKey } from './immich-secret.js' +import { sniffMimeType } from './media-files.js' // Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes. // Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 : @@ -85,12 +86,14 @@ export class ImmichClient { return `${this.apiUrl}${path}` } - private async fetch(path: string): Promise { + private async fetch(path: string, body?: unknown): Promise { const url = this.buildUrl(path) let response: Response try { response = await fetch(url, { - headers: { 'x-api-key': this.apiKey, Accept: 'application/json' }, + method: body === undefined ? 'GET' : 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + headers: { 'x-api-key': this.apiKey, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS), // Aucune redirection suivie : la clé ne doit jamais partir vers une autre adresse. redirect: 'error', @@ -126,6 +129,28 @@ export class ImmichClient { } } + async listAssets(page: number, type: 'IMAGE' | 'VIDEO', filters: { query?: string; personId?: string } = {}) { + const query = filters.query?.trim() + const response = await this.fetch(query ? '/api/search/smart' : '/api/search/metadata', { + page, size: 30, type, isTrashed: false, + ...(query ? { query } : {}), + ...(filters.personId ? { personIds: [filters.personId] } : {}), + }) + const payload = await this.readJson(response) as { assets?: { items?: Array<{ id: string; type: string; originalFileName: string }>; nextPage?: string | null } } + if (!Array.isArray(payload.assets?.items)) throw new ImmichUnavailableError('Liste de médias Immich illisible') + return { + assets: payload.assets.items.filter((asset) => isImmichPersonId(asset.id) && asset.type === type).map((asset) => ({ + id: asset.id, type: asset.type, name: asset.originalFileName, + })), + hasNextPage: Boolean(payload.assets.nextPage), + } + } + + async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise { + const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original' + return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes, 5 * 1024 * 1024) + } + // Toutes les personnes (sans les masquées), en suivant la pagination d'Immich. async listPeople(): Promise { const people: ImmichPersonSummary[] = [] @@ -178,9 +203,14 @@ export class ImmichClient { // Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError. // Lue en flux : au-delà de maxBytes, la lecture s'arrête et renvoie null sans charger le reste. async getPersonThumbnail(personId: string, maxBytes: number): Promise { - const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`) + return this.readBytes(`/api/people/${encodeURIComponent(personId)}/thumbnail`, maxBytes) + } + + private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise { + const response = await this.fetch(path) + let limit = response.headers.get('content-type')?.startsWith('image/') ? Math.min(maxBytes, maxImageBytes) : maxBytes const declared = Number(response.headers.get('content-length')) - if (Number.isFinite(declared) && declared > maxBytes) { + if (Number.isFinite(declared) && declared > limit) { await response.body?.cancel().catch(() => {}) return null } @@ -191,12 +221,19 @@ export class ImmichClient { const reader = response.body.getReader() const chunks: Buffer[] = [] let total = 0 + let signature = Buffer.alloc(0) try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength - if (total > maxBytes) { + // Ne pas se fier au Content-Type : même avec une signature répartie sur + // plusieurs chunks, une image est bornée à 5 Mo dès ses premiers octets. + if (signature.length < 12) { + signature = Buffer.concat([signature, Buffer.from(value.subarray(0, 12 - signature.length))]) + if (sniffMimeType(signature, 'video')?.startsWith('image/')) limit = Math.min(limit, maxImageBytes) + } + if (total > limit || (Number.isFinite(declared) && declared > limit)) { await reader.cancel().catch(() => {}) return null } diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 69c053c..9b8c227 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -98,6 +98,49 @@ function buildAvatarRelativePath(treeId: string, personId: string, extension: st } export const immichRoutes: FastifyPluginAsync = async (app) => { + // Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre. + // Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota, + // validation et rattachement au moment), sans exposer d'URL ni de clé Immich au navigateur. + app.get('/trees/:id/immich/assets', async (request, reply) => { + if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' }) + const params = treeParamSchema.safeParse(request.params) + const query = z.object({ + page: z.coerce.number().int().min(1).max(100000).default(1), + type: z.enum(['IMAGE', 'VIDEO']).default('IMAGE'), + q: z.string().trim().max(500).optional(), + personId: z.string().refine(isImmichPersonId).optional(), + }).safeParse(request.query) + if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' }) + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + const client = await getTreeImmichClient(app.prisma, params.data.id) + if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) + try { + reply.header('Cache-Control', 'no-store') + return reply.send(await client.listAssets(query.data.page, query.data.type, { query: query.data.q, personId: query.data.personId })) + } catch (error) { return replyImmichError(request, reply, error) } + }) + + app.get('/trees/:id/immich/assets/:assetId/:variant', async (request, reply) => { + if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' }) + const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' }) + const client = await getTreeImmichClient(app.prisma, params.data.id) + if (!client) return reply.code(503).send({ error: 'immich_not_configured' }) + try { + const thumbnail = params.data.variant !== 'original' + const bytes = await client.getAssetBytes(params.data.assetId, thumbnail, (thumbnail ? 5 : 20) * 1024 * 1024, params.data.variant === 'preview') + if (!bytes) return reply.code(413).send({ error: 'immich_asset_too_large' }) + const mime = sniffMimeType(bytes, 'video') + if (!mime || !(mime.startsWith('image/') || (!thumbnail && mime.startsWith('video/')))) return reply.code(400).send({ error: 'invalid_immich_media' }) + if (mime.startsWith('image/') && bytes.length > MAX_THUMBNAIL_BYTES) return reply.code(413).send({ error: 'immich_asset_too_large' }) + return reply.header('Content-Type', mime).header('X-Content-Type-Options', 'nosniff').header('Cache-Control', 'no-store').send(bytes) + } catch (error) { + if (error instanceof ImmichNotFoundError) return reply.code(404).send({ error: 'immich_asset_not_found' }) + return replyImmichError(request, reply, error) + } + }) + // ── Connexion Immich de l'arbre : lecture, écriture, suppression. Réservées aux // administrateurs de l'arbre ; la clé d'API ne quitte jamais le serveur. diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index a715001..d9b28b5 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -65,6 +65,161 @@ function mockImmichPerson(options: { thumbnailPath?: string; thumbnailStatus?: n }) } +describe('Souvenirs Immich', () => { + it('combine le contexte et un visage sur la recherche intelligente Immich', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?q=repas%20au%20jardin&personId=${immichPersonId}&page=2`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/smart') + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ page: 2, size: 30, type: 'IMAGE', isTrashed: false, query: 'repas au jardin', personIds: [immichPersonId] }) + await app.close() + }) + + it('filtre par visage sans contexte via les métadonnées', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?personId=${immichPersonId}&q=%20%20`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata') + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ personIds: [immichPersonId] }) + await app.close() + }) + + it('refuse un visage invalide et un contexte excessif avant tout appel distant', async () => { + const app = createApp() + const token = await createUserToken(app) + for (const query of ['personId=invalid', `q=${'x'.repeat(501)}`]) { + expect((await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?${query}`, headers: { authorization: `Bearer ${token}` } })).statusCode).toBe(400) + } + expect(fetchMock).not.toHaveBeenCalled() + await app.close() + }) + + it('charge un aperçu agrandi sans demander le fichier original', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes))) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/preview`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(fetchMock.mock.calls[0][0]).toBe(`http://immich.test/api/assets/${immichPersonId}/thumbnail?size=preview`) + expect(response.headers['content-type']).toBe('image/jpeg') + await app.close() + }) + + it('réserve la photothèque aux administrateurs et ne contacte pas Immich en cas de refus', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue(null) + const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets', headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(403) + expect(fetchMock).not.toHaveBeenCalled() + await app.close() + }) + + it('pagine les vidéos sans exposer les métadonnées privées', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [{ id: immichPersonId, type: 'VIDEO', originalFileName: 'famille.mp4', originalPath: '/private/video.mp4' }], nextPage: '3' } })) + const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets?page=2&type=VIDEO', headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ assets: [{ id: immichPersonId, type: 'VIDEO', name: 'famille.mp4' }], hasNextPage: true }) + expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata') + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ page: 2, type: 'VIDEO', size: 30 }) + await app.close() + }) + + it('refuse les originaux trop gros avant de les lire', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(new Response('too large', { headers: { 'content-length': String(21 * 1024 * 1024) } })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(413) + await app.close() + }) + + it('sert une photo avec son type réel et refuse le HTML', async () => { + const app = createApp() + const token = await createUserToken(app) + const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } } + fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes))) + const response = await app.inject(options) + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('image/jpeg') + fetchMock.mockResolvedValueOnce(new Response('unsafe')) + expect((await app.inject(options)).statusCode).toBe(400) + await app.close() + }) + + it.each(['preview', 'original'])('coupe une image à 5 Mo en flux sur %s, même déclarée vidéo', async (variant) => { + const app = createApp() + const token = await createUserToken(app) + const cancel = vi.fn() + let sent = 0 + const stream = new ReadableStream({ + pull(controller) { + // La signature JPEG arrive en trois chunks, sans Content-Length. + if (sent < 3) controller.enqueue(new Uint8Array(jpegBytes.subarray(sent, ++sent))) + else { sent++; controller.enqueue(new Uint8Array(1024 * 1024)) } + }, cancel, + }, { highWaterMark: 0 }) + fetchMock.mockResolvedValueOnce(new Response(stream, { headers: { 'content-type': 'video/mp4' } })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/${variant}`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(413) + expect(cancel).toHaveBeenCalledOnce() + expect(sent).toBe(8) // 3 octets + 5 Mo : aucun chunk supplémentaire lu. + await app.close() + }) + + it('refuse une image annoncée au-dessus de 5 Mo avant toute lecture', async () => { + const app = createApp() + const token = await createUserToken(app) + const pull = vi.fn() + const cancel = vi.fn() + fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), { + headers: { 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) }, + })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(413) + expect(pull).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledOnce() + await app.close() + }) + + it('conserve la limite vidéo de 20 Mo au-delà de 5 Mo', async () => { + const app = createApp() + const token = await createUserToken(app) + const video = Buffer.alloc(6 * 1024 * 1024) + video.set(Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom')])) + fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video))) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(response.rawPayload.equals(video)).toBe(true) + await app.close() + }) + + it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => { + const app = createApp() + const token = await createUserToken(app) + const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } } + const video = Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom'), 0, 0, 0, 0]) + fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video))) + const response = await app.inject(options) + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toBe('video/mp4') + expect(response.rawPayload).toEqual(video) + const cancel = vi.fn() + fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(21 * 1024 * 1024)) }, cancel, + }))) + expect((await app.inject(options)).statusCode).toBe(413) + expect(cancel).toHaveBeenCalled() + await app.close() + }) +}) + async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise { await app.ready() return (app as any).jwt.sign( diff --git a/docs/immich-memories.md b/docs/immich-memories.md new file mode 100644 index 0000000..c84b85c --- /dev/null +++ b/docs/immich-memories.md @@ -0,0 +1,38 @@ +# Ajouter des souvenirs depuis Immich + +Les administrateurs d'un arbre disposant d'une connexion Immich peuvent utiliser +**Depuis Immich** dans **Ajouter des souvenirs**, ou dans les souvenirs d'un moment +de l'onglet **Vie**. Choisir Photos ou Vidéos, parcourir les pages puis sélectionner +un média. Dans un moment, plusieurs médias peuvent être sélectionnés avant de +terminer la sélection. Enregistrer ensuite le souvenir ou le moment. + +Le sélecteur reprend la présentation de Parenthèse. Cliquer sur une photo ouvre +un aperçu agrandi avant de choisir le souvenir. La coche sous chaque vignette +permet aussi une sélection rapide. La recherche par contexte utilise la recherche +intelligente d'Immich (qui doit être activée et avoir indexé la photothèque). +Le filtre par visage utilise les personnes reconnues dans Immich, avec leur +portrait et une recherche par nom. Il peut être combiné au contexte. La clé doit +aussi disposer de `person.read` pour ce filtre et ses portraits. + +Les fichiers sont copiés dans Parenthèse : ils restent disponibles indépendamment +d'Immich après l'enregistrement. Les limites existantes s'appliquent : 5 Mo par +photo, 20 Mo par vidéo et quota de l'arbre. Les formats pris en charge sont ceux +des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Pour les photos, +Parenthèse copie la version preview préparée par Immich, ce qui permet aussi +d'importer les HEIC/RAW dont Immich a généré l'aperçu. Le nom du fichier suit +le format reçu. Les vidéos conservent leur original, sans transcodage ajouté. + +La clé Immich doit disposer de `asset.read`, `asset.view` et `asset.download`, en +plus des permissions utilisées pour les portraits. Contrat : +[spécification officielle Immich](https://github.com/immich-app/immich/blob/main/open-api/immich-openapi-specs.json). + +La photothèque est accessible uniquement aux administrateurs de l'arbre. La clé +et l'adresse Immich restent côté serveur ; la connexion utilise la liste +d'instances autorisées existante. Les téléchargements sont bornés en taille et +en durée, les redirections sont refusées et le type est vérifié dans les octets. +La persistance utilise l'API d'envoi existante : traitement des images, quota, +audit et association à la personne ou au moment. Aucune migration de base requise. + +Cette fonction ne synchronise pas automatiquement les changements Immich et ne +déduplique pas les imports entre deux sessions. Une validation avec une instance +Immich réelle reste nécessaire avant déploiement. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07759af..902ecf5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3312,6 +3312,8 @@ function App() { /> )} ({})) + throw error + } + return response.blob() +} + +// Les photos utilisent la version préparée par Immich, y compris les HEIC/RAW. +// Le nom suit le format réellement reçu ; les vidéos gardent leur original. +export async function downloadImmichMemory(treeId, asset, token, signal) { + const photo = asset.type === 'IMAGE' + const blob = await fetchImmichAsset(treeId, asset.id, token, photo ? 'preview' : 'original', signal) + if (!(photo ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) { + const error = new Error('invalid_immich_media') + error.payload = { error: 'invalid_immich_media' } + throw error + } + const extension = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }[blob.type] + const name = asset.name || asset.id + return new File([blob], photo && extension ? `${name.replace(/\.[^.]+$/, '')}${extension}` : name, { type: blob.type }) +} + // Connexion Immich de l'arbre : métadonnées sûres (jamais la clé, jamais l'adresse) et instances // autorisées par l'opérateur, nommées par leur libellé, dont le panneau d'administration fait un menu. export async function fetchImmichConfig(treeId, token) { diff --git a/frontend/src/api/immichApi.test.js b/frontend/src/api/immichApi.test.js new file mode 100644 index 0000000..d53084f --- /dev/null +++ b/frontend/src/api/immichApi.test.js @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { downloadImmichMemory, fetchImmichAsset, listImmichAssets } from './immichApi.js' + +afterEach(() => vi.unstubAllGlobals()) +describe('Recherche et aperçus Immich', () => { + it('importe une photo HEIC via la preview et adapte son nom au JPEG reçu', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('jpeg', { headers: { 'content-type': 'image/jpeg' } })) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + const file = await downloadImmichMemory('tree', { id: 'photo', name: 'IMG_1234.HEIC', type: 'IMAGE' }, 'token', controller.signal) + expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/photo\/preview$/) + expect(fetch.mock.calls[0][1].signal).toBe(controller.signal) + expect(file.name).toBe('IMG_1234.jpg') + expect(file.type).toBe('image/jpeg') + expect(await file.text()).toBe('jpeg') + }) + it('importe la vidéo originale sans changer son nom', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('video', { headers: { 'content-type': 'video/quicktime' } })) + vi.stubGlobal('fetch', fetch) + const file = await downloadImmichMemory('tree', { id: 'video', name: 'film.MOV', type: 'VIDEO' }, 'token') + expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/video\/original$/) + expect(file.name).toBe('film.MOV') + expect(file.type).toBe('video/quicktime') + }) + it('refuse une réponse vidéo pour une photo', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('heic', { headers: { 'content-type': 'video/mp4' } }))) + await expect(downloadImmichMemory('tree', { id: 'photo', type: 'IMAGE' }, 'token')).rejects.toMatchObject({ payload: { error: 'invalid_immich_media' } }) + }) + it('encode le contexte et conserve le visage, la page et l’annulation', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('{"assets":[],"hasNextPage":false}')) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + await listImmichAssets('arbre/1', 'token', 3, 'IMAGE', { query: ' été & mer ', personId: 'face-1', signal: controller.signal }) + const [url, options] = fetch.mock.calls[0] + const parsed = new URL(url, 'http://localhost') + expect(parsed.pathname).toContain('arbre%2F1') + expect(Object.fromEntries(parsed.searchParams)).toEqual({ page: '3', type: 'IMAGE', q: 'été & mer', personId: 'face-1' }) + expect(options.signal).toBe(controller.signal) + expect(options.headers.Authorization).toBe('Bearer token') + }) + it('ouvre un aperçu sans télécharger l’original', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('image', { headers: { 'content-type': 'image/jpeg' } })) + vi.stubGlobal('fetch', fetch) + const blob = await fetchImmichAsset('tree', 'photo', 'token', 'preview') + expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/photo\/preview$/) + expect(blob.type).toBe('image/jpeg') + }) +}) diff --git a/frontend/src/components/ImmichAssetPicker.css b/frontend/src/components/ImmichAssetPicker.css new file mode 100644 index 0000000..0917a0a --- /dev/null +++ b/frontend/src/components/ImmichAssetPicker.css @@ -0,0 +1,94 @@ +/* Galerie Immich dans la DA terracotta des souvenirs. */ +.iap-overlay { z-index: var(--pz-z-modal-nested); } +.pz-modal.iap { width: min(100%, 850px); padding: 0; gap: 0; overflow: auto; } +.iap-head { padding: 28px 64px 22px 28px; border-bottom: 1px solid var(--pz-line-soft); } +.iap-head .pz-eyebrow { display: flex; align-items: center; gap: 7px; color: var(--pz-a700); } +.iap-head .pz-title { margin: 3px 0 5px; } +.iap-controls { display: grid; gap: 14px; padding: 22px 28px 18px; background: var(--pz-bg); border-bottom: 1px solid var(--pz-line-soft); } +.iap-search { display: flex; align-items: center; gap: 10px; padding: 6px 7px 6px 14px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: 999px; color: var(--pz-faint); } +.iap-search:focus-within { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); } +.iap-search > svg { flex: none; } +.iap-search input { flex: 1; width: 0; min-width: 0; padding: 6px 0; border: 0; background: transparent; outline: none; color: var(--pz-ink); font: 400 14px/1.5 var(--pz-sans); } +.iap-search input::placeholder { color: var(--pz-faint); } +.iap-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.iap-types { min-width: 210px; } +.iap-types .pz-tab { display: inline-flex; align-items: center; justify-content: center; gap: 7px; } +.iap-faces { display: grid; gap: 12px; padding: 16px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); } +.iap-face-list { display: flex; gap: 8px; flex-wrap: wrap; max-height: 180px; overflow-y: auto; } +.iap-face-choice { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 44px; padding: 5px 12px 5px 5px; border: 1px solid var(--pz-line); border-radius: 999px; background: var(--pz-surface); color: var(--pz-ink-2); font: 500 13px var(--pz-sans); cursor: pointer; } +.iap-face-choice > span:not(.iap-face) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.iap-face-choice.is-active { background: var(--pz-a50); border-color: var(--pz-a300); color: var(--pz-a700); } +.iap-face { display: grid; place-items: center; flex: none; width: 30px; height: 30px; overflow: hidden; border-radius: 50%; color: var(--pz-a700); background: var(--pz-a100); } +.iap-face img { width: 100%; height: 100%; object-fit: cover; } +.iap-results { min-height: 200px; padding: 22px 28px; } +.iap-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } +.iap-asset { min-width: 0; border-radius: var(--pz-r-m); background: var(--pz-surface); box-shadow: 0 0 0 1px var(--pz-line); overflow: hidden; transition: box-shadow 160ms ease; } +.iap-asset.is-selected { box-shadow: 0 0 0 2px var(--pz-a500); } +.iap-open { position: relative; display: block; width: 100%; padding: 0; border: 0; background: var(--pz-bg); cursor: zoom-in; aspect-ratio: 4/3; } +.iap-image { position: relative; display: grid; place-items: center; width: 100%; height: 100%; overflow: hidden; background: var(--pz-line-soft); } +.iap-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 220ms var(--pz-ease); } +.iap-image img[hidden] { display: none; } +.iap-placeholder { display: grid; place-items: center; gap: 6px; color: var(--pz-faint); font: 400 12px var(--pz-sans); } +.iap-placeholder .pz-busy { width: 40px; height: 24px; } +.iap-zoom { position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--pz-surface); color: var(--pz-ink-2); box-shadow: var(--pz-shadow); } +.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: var(--pz-media-badge-bg); color: var(--pz-surface); font: 500 12px var(--pz-sans); } +.iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 2px 2px 2px 10px; } +.iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12.5px/1.4 var(--pz-sans); color: var(--pz-muted); } +/* Zone de toucher de 44px, pastille visuelle de 30px centrée dedans */ +.iap-select { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; background: transparent; color: var(--pz-faint); border: 0; border-radius: 50%; cursor: pointer; } +.iap-select::before { content: ""; position: absolute; top: 50%; left: 50%; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--pz-surface); border: 1px solid var(--pz-line); transition: background-color 160ms ease, border-color 160ms ease; } +.iap-select > svg { position: relative; } +.iap-select.is-selected { color: var(--pz-surface); } +.iap-select.is-selected::before { background: var(--pz-a500); border-color: var(--pz-a500); } +.iap-open:disabled, .iap-select:disabled, .iap-face-choice:disabled { cursor: default; } +.iap-select:disabled:not(.is-selected) { opacity: .45; } +.iap :is(.iap-open, .iap-select, .iap-face-choice, .pz-tab):focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -3px; } +.iap-clear-label { display: inline; } +.iap-empty { display: grid; justify-items: center; align-content: center; min-height: 210px; gap: 12px; color: var(--pz-faint); text-align: center; } +.iap-empty > p { margin: 0; font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink-2); } +.iap-empty .pz-busy { width: 40px; height: 24px; } +.iap-pagination { display: flex; align-items: center; justify-content: space-between; padding: 0 22px 18px; } +.iap-footer { position: sticky; bottom: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding: 18px 28px; background: var(--pz-surface); border-top: 1px solid var(--pz-line); z-index: 2; } +.iap-footer > p span { display: block; color: var(--pz-faint); font-size: 12.5px; } +.iap-error { padding: 0 28px 14px; } +.iap-results > .iap-error { padding: 0 0 14px; } +.iap-error .pz-error { margin: 0; } +.iap-preview-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; } +.iap-preview-stage { height: min(56dvh, 520px); margin: 0 20px; overflow: hidden; border-radius: var(--pz-r-m); background: var(--pz-ink); } +.iap-image--large { background: var(--pz-ink); } +.iap-image--large img { width: 100%; height: 100%; object-fit: contain; } +.iap-preview-caption { display: grid; gap: 4px; padding: 14px 28px 20px; } +.iap-preview-caption > p { margin: 0; font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink); overflow-wrap: anywhere; } +@media (hover: hover) and (pointer: fine) { + .iap-open:hover .iap-image img { transform: scale(1.035); } + .iap-asset:hover { box-shadow: 0 0 0 1px var(--pz-a300), var(--pz-shadow); } + .iap-select:hover:not(:disabled) { color: var(--pz-a700); } + .iap-select:hover:not(:disabled):not(.is-selected)::before { background: var(--pz-a50); border-color: var(--pz-a300); } + .iap-face-choice:hover:not(:disabled) { background: var(--pz-a50); color: var(--pz-a700); border-color: var(--pz-a300); } +} +@media (max-width: 640px) { .iap-grid { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; } } +@media (max-width: 560px) { + .pz-modal.iap { max-height: 94dvh; border-radius: var(--pz-r-l) var(--pz-r-l) 0 0; } + .iap-head { padding: 24px 54px 18px 20px; } + .iap-head .pz-title { font-size: 26px; } + .iap-controls { padding: 18px 20px; gap: 12px; } + .iap-search { border-radius: var(--pz-r-m); flex-wrap: wrap; } + .iap-search input { font-size: 16px; } + .iap-search .pz-btn { min-height: 38px; } + .iap-results { min-height: 0; padding: 18px 20px; } + .iap-results > .iap-error { padding: 0 0 14px; } + /* Vidéos/Photos et filtre visage sur une seule ligne */ + .iap-filters { flex-wrap: wrap; gap: 6px; } + .iap-types { flex: 1 1 0; min-width: 0; } + .iap-types .pz-tab { padding: 0 8px; } + .iap-types .pz-tab svg, .iap-clear-label { display: none; } + .iap-face-btn { flex: 0 1 auto; min-width: 0; max-width: 100%; } + .iap-face-btn > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .iap-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; } + .iap-footer { padding: 16px 20px calc(16px + env(safe-area-inset-bottom)); gap: 10px; } + .iap-footer > .pz-btn { flex: 1; } + .iap-footer > p { width: 100%; } + .iap-preview-stage { height: 48dvh; margin: 0 12px; } + .iap-preview-caption { padding: 14px 20px; } +} +@media (prefers-reduced-motion: reduce) { .iap-asset, .iap-image img { transition: none; } } diff --git a/frontend/src/components/ImmichAssetPicker.jsx b/frontend/src/components/ImmichAssetPicker.jsx new file mode 100644 index 0000000..b989ebf --- /dev/null +++ b/frontend/src/components/ImmichAssetPicker.jsx @@ -0,0 +1,169 @@ +import { useEffect, useId, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ArrowLeft, Check, ChevronLeft, ChevronRight, Image, Search, Sparkles, UserRound, Video, X, ZoomIn } from 'lucide-react' +import { downloadImmichMemory, fetchImmichAsset, fetchImmichThumbnail, getImmichErrorMessage, listImmichAssets, listImmichPeople } from '../api/immichApi.js' +import { t } from '../i18n/index.js' +import PzBusy from './PzBusy.jsx' +import './ImmichAssetPicker.css' + +function AssetImage({ treeId, token, asset, large = false }) { + const [url, setUrl] = useState('') + const [failed, setFailed] = useState(false) + const imageRef = useRef(null) + useEffect(() => { + const controller = new AbortController() + let objectUrl, observer + setUrl(''); setFailed(false) + const load = () => { + observer?.disconnect() + fetchImmichAsset(treeId, asset.id, token, large ? 'preview' : 'thumbnail', controller.signal).then((blob) => { + if (!controller.signal.aborted) { objectUrl = URL.createObjectURL(blob); setUrl(objectUrl) } + }).catch(() => { if (!controller.signal.aborted) setFailed(true) }) + } + if (large || typeof IntersectionObserver === 'undefined') load() + else { + observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) load() }, { rootMargin: '100px' }) + observer.observe(imageRef.current) + } + return () => { controller.abort(); observer?.disconnect(); if (objectUrl) URL.revokeObjectURL(objectUrl) } + }, [treeId, token, asset.id, large]) + return + {url && {large setFailed(true)} hidden={failed} />} + {(!url || failed) && {failed ? <>{t('Aperçu indisponible')} : }} + +} + +function Face({ treeId, token, person }) { + const [url, setUrl] = useState('') + useEffect(() => { + let cancelled = false, objectUrl + if (person.hasThumbnail) fetchImmichThumbnail(treeId, person.id, token).then((value) => { + objectUrl = value + if (cancelled) URL.revokeObjectURL(value) + else setUrl(value) + }).catch(() => {}) + return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl) } + }, [treeId, token, person.id, person.hasThumbnail]) + return +} + +export default function ImmichAssetPicker({ treeId, token, onSelect, onClose }) { + const uid = useId() + const [search, setSearch] = useState({ page: 1, type: 'IMAGE', query: '', personId: '' }) + const [draft, setDraft] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [selected, setSelected] = useState([]) + const [preview, setPreview] = useState(null) + const [error, setError] = useState('') + const [people, setPeople] = useState(null) + const [faceOpen, setFaceOpen] = useState(false) + const [faceQuery, setFaceQuery] = useState('') + const [faceError, setFaceError] = useState('') + const download = useRef(null), dialog = useRef(null), searchInput = useRef(null) + const previewTrigger = useRef(null) + useEffect(() => { + const previous = document.activeElement + searchInput.current?.focus() + return () => { download.current?.abort(); previous?.focus() } + }, []) + useEffect(() => { + const controller = new AbortController() + setLoading(true); setError(''); setResult(null) + listImmichAssets(treeId, token, search.page, search.type, { ...search, signal: controller.signal }).then((data) => { + if (!controller.signal.aborted) setResult(data) + }).catch((err) => { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) + return () => controller.abort() + }, [treeId, token, search]) + useEffect(() => { + let cancelled = false + listImmichPeople(treeId, token).then((data) => { if (!cancelled) setPeople(data) }).catch((err) => { + if (!cancelled) { setPeople([]); setFaceError(getImmichErrorMessage(err, t)) } + }) + return () => { cancelled = true } + }, [treeId, token]) + const changeSearch = (values) => setSearch((current) => ({ ...current, ...values, page: 1 })) + const submitSearch = () => changeSearch({ query: draft.trim() }) + const selectedFace = people?.find((person) => person.id === search.personId) + const filteredPeople = (people || []).filter((person) => !faceQuery.trim() || person.name.toLocaleLowerCase().includes(faceQuery.trim().toLocaleLowerCase())) + const closePreview = () => { setPreview(null); requestAnimationFrame(() => dialog.current?.querySelector(`[data-asset-id="${previewTrigger.current}"]`)?.focus()) } + const handleKey = (event) => { + event.stopPropagation() + if (event.key === 'Escape') { event.preventDefault(); if (!busy) { if (preview) closePreview(); else onClose() } } + if (event.key === 'Tab') { + const elements = [...dialog.current.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex="0"]')].filter((element) => element.getClientRects().length) + const first = elements[0], last = elements.at(-1) + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { event.preventDefault(); last?.focus() } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() } + } + } + const select = async (asset) => { + if (busy || selected.includes(asset.id)) return + setBusy(true); setError('') + const controller = new AbortController() + download.current = controller + try { + const file = await downloadImmichMemory(treeId, asset, token, controller.signal) + if (controller.signal.aborted) return + onSelect(file) + if (!controller.signal.aborted) setSelected((ids) => [...ids, asset.id]) + } catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) } + finally { if (!controller.signal.aborted) setBusy(false) } + } + // Erreur Immich : affichée en haut des résultats (visible sans défiler sur téléphone), ou sous l'aperçu + const errorBlock =

{error}

{!preview && }
+ return createPortal(
event.stopPropagation()} onMouseDown={(event) => { if (event.target === event.currentTarget && !busy) onClose() }} onKeyDown={handleKey}> +
+ +
+

{t('Depuis Immich')}

+

{preview ? t('Un souvenir à regarder de plus près') : t('Retrouver vos souvenirs')}

+
+ {preview ? <> +
{preview.type === 'VIDEO' ? t('Vidéo') : t('Photo')}
+
+

{preview.name}

{preview.type === 'VIDEO' && {t('Aperçu de la vidéo')}}
+ : <> +
+
+
+
+
+ {[['IMAGE', t('Photos'), Image], ['VIDEO', t('Vidéos'), Video]].map(([value, label, Icon]) => )} +
+ + {(search.query || search.personId) && } +
+ {faceOpen &&
+ + {people === null ?

{t('Chargement…')}

:
+ + {filteredPeople.slice(0, 30).map((person) => )} +
} + {filteredPeople.length > 30 &&

{t('Précisez un nom pour voir les autres visages.')}

} + {people?.length === 0 &&

{t('Aucune personne dans Immich.')}

} + {people?.length > 0 && filteredPeople.length === 0 &&

{t('Aucune personne trouvée.')}

} + {faceError &&

{faceError}

} +
} +
+
+ {error && !preview && errorBlock} + {loading ?

{t('À la recherche de vos souvenirs…')}

: result?.assets.length ?
{result.assets.map((asset) =>
+ +
{asset.name}
+
)}
: !error &&

{t('Aucun média trouvé.')}

{t('Essayez une autre description ou un autre visage.')}
} +
+ {!loading && result && } + } + {error && preview && errorBlock} +

{selected.length === 1 ? t('1 souvenir sélectionné') : selected.length > 1 ? t('{n} souvenirs sélectionnés', { n: selected.length }) : t('Photo : 5 Mo · Vidéo : 20 Mo')}{t('Vos médias seront copiés dans Parenthèse.')}

+ {preview ? : } +
+
+
, document.body) +} diff --git a/frontend/src/components/MediaManagerPanel.css b/frontend/src/components/MediaManagerPanel.css index 5f869e9..246d6ed 100644 --- a/frontend/src/components/MediaManagerPanel.css +++ b/frontend/src/components/MediaManagerPanel.css @@ -37,6 +37,11 @@ } @media (hover: hover) and (pointer: fine) { .mm-drop:hover { background: var(--pz-a100); } } .mm-drop-input:focus-visible + .mm-drop { outline: 2px solid var(--pz-a500); outline-offset: 2px; } +.mm-drop-link { padding: 0; border: 0; background: none; cursor: pointer; font: inherit; font-weight: 500; color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; } +.mm-drop-link:disabled { cursor: default; opacity: .6; } +.mm-immich-queue { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.mm-immich-queue li { display: flex; align-items: center; gap: 10px; min-width: 0; } +.mm-immich-queue span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); } .mm-drop-icon { width: 40px; height: 40px; flex: none; display: grid; place-items: center; border-radius: 12px; background: var(--pz-surface); color: var(--pz-a700); } .mm-drop-text { display: grid; gap: 2px; min-width: 0; } .mm-drop-text strong { font: 500 14px var(--pz-sans); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/frontend/src/components/MediaManagerPanel.jsx b/frontend/src/components/MediaManagerPanel.jsx index 4ff9ec8..952b156 100644 --- a/frontend/src/components/MediaManagerPanel.jsx +++ b/frontend/src/components/MediaManagerPanel.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { readContributorName } from '../utils/contributorName' import { ArrowDown, ArrowUp, FileText, Image, MapPin, Mic, Plus, Quote, Route, Trash2, Upload, Video, X } from 'lucide-react' import { getAcceptForMediaType } from '../utils/mediaUpload' @@ -6,6 +6,8 @@ import GeoMediaThumbnail from './GeoMediaThumbnail' import './MediaManagerPanel.css' import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' +import ImmichAssetPicker from './ImmichAssetPicker.jsx' +import { fetchImmichConfig } from '../api/immichApi.js' const MEDIA_TYPES = [ { value: 'photo', label: t('Photo'), Icon: Image }, @@ -32,6 +34,8 @@ const MediaManagerPanel = ({ errorMessage, notice = '', canManage = true, + treeId, + token, onClose, onUpload, onMove, @@ -40,6 +44,21 @@ const MediaManagerPanel = ({ const [caption, setCaption] = useState('') const [source, setSource] = useState('') const [file, setFile] = useState(null) + const [immichConfigured, setImmichConfigured] = useState(false) + const [pickingImmich, setPickingImmich] = useState(false) + // Souvenirs choisis dans Immich : un souvenir par fichier, envoyés l'un après l'autre à l'enregistrement + const [immichFiles, setImmichFiles] = useState([]) + const [sending, setSending] = useState(false) + const fileInput = useRef(null) + const immichAvailable = canManage && immichConfigured + useEffect(() => { + let cancelled = false + if (visible && canManage && treeId && token) fetchImmichConfig(treeId, token).then(({ config }) => { + if (!cancelled) setImmichConfigured(Boolean(config?.configured)) + }).catch(() => { if (!cancelled) setImmichConfigured(false) }) + return () => { cancelled = true } + }, [visible, canManage, treeId, token]) + useEffect(() => { if (!visible) { setPickingImmich(false); setImmichConfigured(false); setImmichFiles([]) } }, [visible]) const [mediaType, setMediaType] = useState('photo') const [citationText, setCitationText] = useState('') const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube' @@ -53,7 +72,7 @@ const MediaManagerPanel = ({ const isCitation = mediaType === 'citation' const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube' const canUpload = useMemo(() => { - if (loading) { + if (loading || pickingImmich || sending) { return false } @@ -61,6 +80,10 @@ const MediaManagerPanel = ({ return false } + if (immichFiles.length > 0) { + return true + } + if (isCitation) { return citationText.trim().length > 0 } @@ -70,7 +93,7 @@ const MediaManagerPanel = ({ } return Boolean(file) - }, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, youtubeUrl]) + }, [canManage, citationText, contributorName, file, immichFiles, isCitation, isYoutubeVideo, loading, pickingImmich, sending, youtubeUrl]) const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType]) if (!visible || !person) { @@ -83,16 +106,39 @@ const MediaManagerPanel = ({ return } - await onUpload({ - file, - caption: caption.trim() || null, - source: source.trim() || null, - mediaType, - citationText: citationText.trim(), - youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null, - submittedByLabel: canManage ? null : contributorName.trim(), - }) + if (immichFiles.length > 0) { + // Un souvenir par fichier Immich, titré par son nom de fichier ; on s'arrête à la première erreur + setSending(true) + let left = immichFiles + for (const item of immichFiles) { + const ok = await onUpload({ + file: item.file, + caption: item.file.name.replace(/\.[^.]+$/, '') || null, + source: source.trim() || null, + mediaType: item.file.type.startsWith('video/') ? 'video' : 'photo', + citationText: '', + youtubeUrl: null, + submittedByLabel: canManage ? null : contributorName.trim(), + }) + if (ok === false) break + left = left.filter((entry) => entry.key !== item.key) + setImmichFiles(left) + } + setSending(false) + if (left.length > 0) return + } else { + await onUpload({ + file, + caption: caption.trim() || null, + source: source.trim() || null, + mediaType, + citationText: citationText.trim(), + youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null, + submittedByLabel: canManage ? null : contributorName.trim(), + }) + } setFile(null) + setImmichFiles([]) setCaption('') setSource('') setMediaType('photo') @@ -127,11 +173,21 @@ const MediaManagerPanel = ({ const changeType = (value) => { setMediaType(value) setFile(null) + setImmichFiles([]) setCitationText('') setVideoMode('file') setYoutubeUrl('') } + // Un souvenir choisi dans Immich s'ajoute à la liste ; la galerie reste ouverte pour en cocher d'autres + const addImmichFile = (picked) => { + setFile(null) + if (fileInput.current) fileInput.current.value = '' + setImmichFiles((list) => (list.some((entry) => entry.file.name === picked.name && entry.file.size === picked.size) + ? list + : [...list, { key: `${picked.name}-${picked.size}`, file: picked }])) + } + return (
@@ -174,24 +230,49 @@ const MediaManagerPanel = ({
)} - {!isCitation && !isYoutubeVideo && ( -
- - setFile(event.target.files?.[0] || null)} - /> -
- )} + {pickingImmich && canManage && setPickingImmich(false)} onSelect={addImmichFile} />} + {!isCitation && !isYoutubeVideo && (() => { + // Immich ne donne que des photos et des vidéos : le lien n'apparaît que pour ces deux types + const withImmich = immichAvailable && (mediaType === 'photo' || mediaType === 'video') + // Le lien est un bouton dans le label : un clic dessus n'ouvre pas le sélecteur de fichiers + const immichLink = withImmich && + const count = immichFiles.length + const title = count === 1 ? t('1 souvenir choisi dans Immich') + : count > 1 ? t('{n} souvenirs choisis dans Immich', { n: count }) + : file ? file.name : t('Choisir un fichier') + const hint = count > 0 ? <>{t('En ajouter d’autres')} {immichLink} + : file ? t('Pour en prendre un autre, touchez ici') + : mediaType === 'photo' ? t('5 Mo au maximum') : t('20 Mo au maximum') + return ( +
+ + { setFile(event.target.files?.[0] || null); setImmichFiles([]) }} + /> + {count > 0 && ( +
    + {immichFiles.map((item) => ( +
  • + {item.file.name} + +
  • + ))} +
+ )} +
+ ) + })()} {isYoutubeVideo && (
@@ -220,7 +301,7 @@ const MediaManagerPanel = ({ )}
-
+ {immichFiles.length === 0 &&
setCaption(event.target.value)} placeholder={t('Été à Quiberon, 1978')} /> -
+
} {canManage &&
diff --git a/frontend/src/components/MomentSheet.jsx b/frontend/src/components/MomentSheet.jsx index 54cfdc2..a17d51d 100644 --- a/frontend/src/components/MomentSheet.jsx +++ b/frontend/src/components/MomentSheet.jsx @@ -8,6 +8,8 @@ import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js' import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js' import { readContributorName, saveContributorName } from '../utils/contributorName.js' import './MomentSheet.css' +import ImmichAssetPicker from './ImmichAssetPicker.jsx' +import { fetchImmichConfig } from '../api/immichApi.js' // Fiche « Ajouter un moment » de l'onglet Vie : quoi, quand, où, et des souvenirs (photo, vidéo, acte en // PDF, enregistrement). La date exacte, la fin, la source et la note restent dans « Plus de détails ». @@ -49,6 +51,15 @@ export default function MomentSheet({ person, entry = null, treeId, token, media const [linkedIds, setLinkedIds] = useState(() => (entry?.mediaIds || []).map(String)) const [files, setFiles] = useState([]) const [picking, setPicking] = useState(false) + const [immichConfigured, setImmichConfigured] = useState(false) + const [pickingImmich, setPickingImmich] = useState(false) + useEffect(() => { + let cancelled = false + if (isAdmin) fetchImmichConfig(treeId, token).then(({ config }) => { + if (!cancelled) setImmichConfigured(Boolean(config?.configured)) + }).catch(() => {}) + return () => { cancelled = true } + }, [isAdmin, treeId, token]) const [name, setName] = useState(() => readContributorName() || '') const [busy, setBusy] = useState(false) const [error, setError] = useState('') @@ -79,14 +90,22 @@ export default function MomentSheet({ person, entry = null, treeId, token, media const pickable = medias.filter((m) => m.type !== 'citation') const yearValid = !year || /^\d{4}$/.test(year) const endValid = !endYear || (/^\d{4}$/.test(endYear) && (!year || Number(endYear) >= Number(year))) - const canSave = (milestone || what.trim().length > 0) && yearValid && endValid && !busy + const canSave = (milestone || what.trim().length > 0) && yearValid && endValid && !busy && !pickingImmich const addFiles = (list) => { const next = [...list].map((file) => { const type = inferMediaTypeFromMimeType(file.type) return { key: `${file.name}-${file.size}-${file.lastModified}`, file, type, preview: type === 'photo' ? URL.createObjectURL(file) : '' } }) - setFiles((current) => [...current, ...next.filter((n) => !current.some((c) => c.key === n.key))]) + setFiles((current) => { + const merged = [...current] + for (const item of next) { + if (merged.some((existing) => existing.key === item.key)) { + if (item.preview) URL.revokeObjectURL(item.preview) + } else merged.push(item) + } + return merged + }) } const toggleLinked = (id) => setLinkedIds((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id])) @@ -239,6 +258,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media )}
+ {isAdmin && immichConfigured && } )}
+ {pickingImmich && addFiles([file])} onClose={() => setPickingImmich(false)} />} { addFiles(e.target.files || []); e.target.value = '' }} /> {picking && (
diff --git a/frontend/src/hooks/useMediaManager.js b/frontend/src/hooks/useMediaManager.js index 58f820a..8318021 100644 --- a/frontend/src/hooks/useMediaManager.js +++ b/frontend/src/hooks/useMediaManager.js @@ -62,7 +62,7 @@ export function useMediaManager({ submittedByLabel = null, }) => { if (!canUploadMedia || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) { - return + return false } // Prénom de la personne qui propose (famille), retenu pour la fois suivante @@ -105,7 +105,7 @@ export function useMediaManager({ loading: false, error: validationError, })) - return + return false } setMediaManagerState((current) => ({ @@ -157,12 +157,14 @@ export function useMediaManager({ error: '', notice: noticeFor(created), })) + return true } catch (error) { setMediaManagerState((current) => ({ ...current, loading: false, error: getAccountErrorMessage(error), })) + return false } } diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index ed2c94f..dd0143a 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -286,6 +286,53 @@ export default { "L'ordre n'a pas pu être enregistré. Réessayez.": 'The order could not be saved. Please try again.', // Visages venus d'Immich (PersonCard, ImmichFacePanel, immichApi) 'Depuis Immich': 'From Immich', + 'Aperçu indisponible': 'Preview unavailable', + 'Un souvenir à regarder de plus près': 'A memory to take a closer look at', + 'Retrouver vos souvenirs': 'Find your memories', + 'Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.': 'Open an image for a closer look, then add it to memories.', + 'Retour aux photos': 'Back to photos', + 'Aperçu de la vidéo': 'Video preview', + 'Recherche dans Immich': 'Search Immich', + 'Rechercher': 'Search', + 'Un repas au jardin, des vacances à la mer…': 'A meal in the garden, a seaside holiday…', + 'Décrire la photo recherchée': 'Describe the photo you are looking for', + 'Décrire la vidéo recherchée': 'Describe the video you are looking for', + 'ou': 'or', + 'depuis Immich': 'from Immich', + '1 souvenir choisi dans Immich': '1 memory picked from Immich', + '{n} souvenirs choisis dans Immich': '{n} memories picked from Immich', + 'Souvenirs choisis dans Immich': 'Memories picked from Immich', + 'En ajouter d’autres': 'Add more', + 'Ajouter ces {n} souvenirs': 'Add these {n} memories', + 'Filtrer par visage': 'Filter by face', + 'Effacer les filtres': 'Clear filters', + 'Personne reconnue dans Immich': 'Person recognized in Immich', + 'Tous les visages': 'All faces', + 'Précisez un nom pour voir les autres visages.': 'Enter a name to see more faces.', + 'La recherche par contexte utilise la recherche intelligente activée dans Immich.': 'Context search uses smart search enabled in Immich.', + 'À la recherche de vos souvenirs…': 'Looking for your memories…', + 'Agrandir {name}': 'Enlarge {name}', + 'Sélectionner {name}': 'Select {name}', + 'Essayez une autre description ou un autre visage.': 'Try another description or face.', + 'Pages des souvenirs Immich': 'Immich memories pages', + 'Page {page}': 'Page {page}', + '1 souvenir sélectionné': '1 memory selected', + '{n} souvenirs sélectionnés': '{n} memories selected', + 'Photo : 5 Mo · Vidéo : 20 Mo': 'Photo: 5 MB · Video: 20 MB', + 'Vos médias seront copiés dans Parenthèse.': 'Your media will be copied to Parenthèse.', + 'Choisir ce souvenir': 'Choose this memory', + 'Fichier trop volumineux (photo : 5 Mo, vidéo : 20 Mo).': 'File too large (photo: 5 MB, video: 20 MB).', + 'Média introuvable dans Immich.': 'Media not found in Immich.', + 'Format de média non pris en charge.': 'Unsupported media format.', + 'Type': 'Type', + 'Photos': 'Photos', + 'Vidéos': 'Videos', + 'Ajouté': 'Added', + 'Aucun média trouvé.': 'No media found.', + 'Téléchargement…': 'Downloading…', + 'Précédent': 'Previous', + 'Suivant': 'Next', + 'Terminer la sélection': 'Finish selecting', 'Depuis un fichier': 'From a file', 'Source du portrait': 'Portrait source', 'Visage Immich': 'Immich face', diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css index ef32ef0..8820844 100644 --- a/frontend/src/styles/pz.css +++ b/frontend/src/styles/pz.css @@ -25,6 +25,8 @@ --pz-success-bg: #e6f2e4; --pz-shadow: 0 1px 2px rgba(42, 38, 34, .05), 0 8px 24px rgba(42, 38, 34, .07); --pz-shadow-lg: 0 30px 80px rgba(42, 38, 34, .22), 0 2px 6px rgba(42, 38, 34, .06); + --pz-media-badge-bg: rgba(42, 38, 34, .75); + --pz-z-modal-nested: 2400; --pz-serif: 'Newsreader', Georgia, 'Times New Roman', serif; --pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif; --pz-ease: cubic-bezier(.23, 1, .32, 1);