From ae77105cd05f7d22eda81e964ca28fb7cf252917 Mon Sep 17 00:00:00 2001 From: Askrosm <155456680+Askrosm@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:44:15 +0200 Subject: [PATCH] feat: import Immich memories with context and face search and large previews --- backend/src/lib/immich.ts | 34 +++- backend/src/routes/immich.ts | 43 +++++ backend/test/immich.test.ts | 108 +++++++++++ docs/immich-memories.md | 36 ++++ frontend/src/App.jsx | 2 + frontend/src/api/immichApi.js | 23 +++ frontend/src/api/immichApi.test.js | 25 +++ frontend/src/components/ImmichAssetPicker.css | 78 ++++++++ frontend/src/components/ImmichAssetPicker.jsx | 171 ++++++++++++++++++ frontend/src/components/MediaManagerPanel.jsx | 22 ++- frontend/src/components/MomentSheet.jsx | 25 ++- frontend/src/i18n/en/fiche.js | 41 +++++ 12 files changed, 601 insertions(+), 7 deletions(-) create mode 100644 docs/immich-memories.md create mode 100644 frontend/src/api/immichApi.test.js create mode 100644 frontend/src/components/ImmichAssetPicker.css create mode 100644 frontend/src/components/ImmichAssetPicker.jsx diff --git a/backend/src/lib/immich.ts b/backend/src/lib/immich.ts index 8645bde..911a9f2 100644 --- a/backend/src/lib/immich.ts +++ b/backend/src/lib/immich.ts @@ -85,12 +85,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 +128,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) + } + // Toutes les personnes (sans les masquées), en suivant la pagination d'Immich. async listPeople(): Promise { const people: ImmichPersonSummary[] = [] @@ -178,7 +202,11 @@ 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): Promise { + const response = await this.fetch(path) const declared = Number(response.headers.get('content-length')) if (Number.isFinite(declared) && declared > maxBytes) { await response.body?.cancel().catch(() => {}) diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 9e11849..26ad66f 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -97,6 +97,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..3bf845f 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -65,6 +65,114 @@ 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('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..9da198c --- /dev/null +++ b/docs/immich-memories.md @@ -0,0 +1,36 @@ +# 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. Aucun transcodage des +originaux HEIC/RAW ni des codecs vidéo n'est 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 07f2d45..0911cf5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3010,6 +3010,8 @@ function App() { /> )} ({})) + throw error + } + return response.blob() +} + // 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..782efaa --- /dev/null +++ b/frontend/src/api/immichApi.test.js @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchImmichAsset, listImmichAssets } from './immichApi.js' + +afterEach(() => vi.unstubAllGlobals()) +describe('Recherche et aperçus Immich', () => { + 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..8b58410 --- /dev/null +++ b/frontend/src/components/ImmichAssetPicker.css @@ -0,0 +1,78 @@ +/* Galerie Immich dans la DA terracotta des souvenirs. */ +.iap-overlay { z-index: 2400; } +.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%; 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 11px 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: rgba(255,255,255,.92); color: var(--pz-ink-2); box-shadow: 0 2px 8px rgba(42,38,34,.1); } +.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: rgba(42,38,34,.75); color: white; font: 500 11px var(--pz-sans); } +.iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 8px 10px; } +.iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12px/1.4 var(--pz-sans); color: var(--pz-muted); } +.iap-select { display: grid; place-items: center; width: 30px; height: 30px; flex: none; padding: 0; background: var(--pz-surface); color: var(--pz-faint); border: 1px solid var(--pz-line); border-radius: 50%; cursor: pointer; } +.iap-select.is-selected { background: var(--pz-a500); border-color: var(--pz-a500); color: white; } +.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-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 23px/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: 12px; } +.iap-error { padding: 0 28px 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 22px/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), .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: 26px 26px 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 { padding: 18px 20px; } + .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..293faea --- /dev/null +++ b/frontend/src/components/ImmichAssetPicker.jsx @@ -0,0 +1,171 @@ +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 { 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 blob = await fetchImmichAsset(treeId, asset.id, token, 'original', controller.signal) + if (controller.signal.aborted) return + if (!(asset.type === 'IMAGE' ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) { + const error = new Error('invalid_immich_media'); error.payload = { error: 'invalid_immich_media' }; throw error + } + onSelect(new File([blob], asset.name || asset.id, { type: blob.type })) + 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) } + } + 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')}

+

{t('Choisissez une image pour l’agrandir, puis ajoutez-la aux 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}

} +
} +

{t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')}

+
+
+ {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 &&

{error}

{!preview && }
} +

{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.jsx b/frontend/src/components/MediaManagerPanel.jsx index 4ff9ec8..789f101 100644 --- a/frontend/src/components/MediaManagerPanel.jsx +++ b/frontend/src/components/MediaManagerPanel.jsx @@ -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,16 @@ 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) + 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) } }, [visible]) const [mediaType, setMediaType] = useState('photo') const [citationText, setCitationText] = useState('') const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube' @@ -53,7 +67,7 @@ const MediaManagerPanel = ({ const isCitation = mediaType === 'citation' const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube' const canUpload = useMemo(() => { - if (loading) { + if (loading || pickingImmich) { return false } @@ -70,7 +84,7 @@ const MediaManagerPanel = ({ } return Boolean(file) - }, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, youtubeUrl]) + }, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, pickingImmich, youtubeUrl]) const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType]) if (!visible || !person) { @@ -174,6 +188,10 @@ const MediaManagerPanel = ({ )} + {canManage && immichConfigured && } + {pickingImmich && canManage && setPickingImmich(false)} onSelect={(selectedFile) => { + setFile(selectedFile); setMediaType(selectedFile.type.startsWith('video/') ? 'video' : 'photo'); setVideoMode('file'); setPickingImmich(false) + }} />} {!isCitation && !isYoutubeVideo && (