Merge pull request #128 from IliesAllali/feat/immich-memories-retouches

Souvenirs depuis Immich (#103 d'Askrosm) et retouches de design
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-21 17:09:48 +02:00
commit e064ee5040
15 files changed
+824 -42

No files matched your search

+42 -5
View File
@@ -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<Response> {
private async fetch(path: string, body?: unknown): Promise<Response> {
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<Buffer | null> {
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<ImmichPersonSummary[]> {
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<Buffer | null> {
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<Buffer | null> {
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
}
+43
View File
@@ -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.
+155
View File
@@ -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('<html>unsafe</html>'))
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<string> {
await app.ready()
return (app as any).jwt.sign(
+38
View File
@@ -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.
+2
View File
@@ -3312,6 +3312,8 @@ function App() {
/>
)}
<MediaManagerPanel
treeId={tree.treeContext.treeId}
token={tree.treeContext.accessToken || auth.userAuth.token}
visible={media.mediaManagerState.visible}
person={tree.selectedPerson}
medias={media.selectedPersonMedias}
+38
View File
@@ -12,6 +12,9 @@ function withAuth(options = {}, token) {
export function getImmichErrorMessage(error, t) {
const code = error?.payload?.error || ''
if (code === 'immich_asset_too_large') return t('Fichier trop volumineux (photo : 5 Mo, vidéo : 20 Mo).')
if (code === 'immich_asset_not_found') return t('Média introuvable dans Immich.')
if (code === 'invalid_immich_media') return t('Format de média non pris en charge.')
if (code === 'immich_not_configured') return t('Immich n’est pas configuré pour cet arbre.')
if (code === 'immich_unreachable') return t('Immich est injoignable.')
if (code === 'immich_auth_failed') return t('La clé d’accès à Immich a été refusée.')
@@ -26,6 +29,41 @@ export function getImmichErrorMessage(error, t) {
return error?.message || t('Erreur inattendue.')
}
export function listImmichAssets(treeId, token, page = 1, type = 'IMAGE', { query, personId, signal } = {}) {
const search = new URLSearchParams({ page: String(page), type })
if (query?.trim()) search.set('q', query.trim())
if (personId) search.set('personId', personId)
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assets?${search}`, withAuth({ signal }, token))
}
export async function fetchImmichAsset(treeId, assetId, token, variant = 'thumbnail', signal) {
const response = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/immich/assets/${encodeURIComponent(assetId)}/${variant}`, {
headers: { Authorization: `Bearer ${token}` }, signal,
})
if (!response.ok) {
const error = new Error('immich_asset_failed')
error.status = response.status
error.payload = await response.json().catch(() => ({}))
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) {
+48
View File
@@ -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')
})
})
@@ -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; } }
@@ -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 <span ref={imageRef} className={`iap-image${large ? ' iap-image--large' : ''}`}>
{url && <img src={url} alt={large ? asset.name : ''} onError={() => setFailed(true)} hidden={failed} />}
{(!url || failed) && <span className="iap-placeholder">{failed ? <><Image size={26} /><span>{t('Aperçu indisponible')}</span></> : <PzBusy busy busyLabel={t('Chargement…')} />}</span>}
</span>
}
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 <span className="iap-face" aria-hidden="true">{url ? <img src={url} alt="" /> : <UserRound size={18} />}</span>
}
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 = <div className="iap-error"><p className="pz-error" role="alert">{error}</p>{!preview && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || loading} onClick={() => setSearch((current) => ({ ...current }))}>{t('Réessayer')}</button>}</div>
return createPortal(<div className="pz-overlay iap-overlay" onClick={(event) => event.stopPropagation()} onMouseDown={(event) => { if (event.target === event.currentTarget && !busy) onClose() }} onKeyDown={handleKey}>
<section ref={dialog} className="pz-modal iap" role="dialog" aria-modal="true" aria-labelledby={`${uid}-title`} tabIndex={-1}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')} disabled={busy}><X size={18} /></button>
<header className="pz-modal-head iap-head">
<p className="pz-eyebrow"><Sparkles size={14} />{t('Depuis Immich')}</p>
<h2 id={`${uid}-title`} className="pz-title">{preview ? t('Un souvenir à regarder de plus près') : t('Retrouver vos souvenirs')}</h2>
</header>
{preview ? <>
<div className="iap-preview-toolbar"><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={closePreview} disabled={busy}><ArrowLeft size={16} />{t('Retour aux photos')}</button><span className="pz-tag">{preview.type === 'VIDEO' ? t('Vidéo') : t('Photo')}</span></div>
<div className="iap-preview-stage"><AssetImage key={preview.id} treeId={treeId} token={token} asset={preview} large /></div>
<div className="iap-preview-caption"><p>{preview.name}</p>{preview.type === 'VIDEO' && <span className="pz-small">{t('Aperçu de la vidéo')}</span>}</div>
</> : <>
<div className="iap-controls">
<div className="iap-search" role="search" aria-label={t('Recherche dans Immich')}>
<Search size={18} aria-hidden="true" /><input ref={searchInput} value={draft} maxLength={500} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch() } }} placeholder={t('Un repas au jardin, des vacances à la mer…')} title={t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')} aria-label={search.type === 'VIDEO' ? t('Décrire la vidéo recherchée') : t('Décrire la photo recherchée')} disabled={busy} />
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={submitSearch} disabled={busy}>{t('Rechercher')}</button>
</div>
<div className="iap-filters">
<div className="pz-tabs iap-types" role="group" aria-label={t('Type')}>
{[['IMAGE', t('Photos'), Image], ['VIDEO', t('Vidéos'), Video]].map(([value, label, Icon]) => <button key={value} type="button" className={`pz-tab${search.type === value ? ' is-active' : ''}`} aria-pressed={search.type === value} onClick={() => changeSearch({ type: value })} disabled={busy}><Icon size={15} />{label}</button>)}
</div>
<button type="button" className={`pz-btn pz-btn--sm iap-face-btn ${search.personId ? 'pz-btn--soft' : 'pz-btn--secondary'}`} aria-expanded={faceOpen} onClick={() => setFaceOpen((value) => !value)} disabled={busy}><UserRound size={16} /><span>{selectedFace?.name || t('Filtrer par visage')}</span></button>
{(search.query || search.personId) && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm iap-clear" aria-label={t('Effacer les filtres')} title={t('Effacer les filtres')} onClick={() => { setDraft(''); changeSearch({ query: '', personId: '' }) }} disabled={busy}><X size={14} /><span className="iap-clear-label">{t('Effacer les filtres')}</span></button>}
</div>
{faceOpen && <div className="iap-faces">
<label className="pz-field"><span>{t('Personne reconnue dans Immich')}</span><input value={faceQuery} onChange={(event) => setFaceQuery(event.target.value)} placeholder={t('Rechercher par nom')} disabled={busy} /></label>
{people === null ? <p className="pz-small" role="status">{t('Chargement…')}</p> : <div className="iap-face-list">
<button type="button" className={`iap-face-choice${!search.personId ? ' is-active' : ''}`} onClick={() => { changeSearch({ personId: '' }); setFaceOpen(false) }} disabled={busy}><span className="iap-face"><UserRound size={18} /></span>{t('Tous les visages')}</button>
{filteredPeople.slice(0, 30).map((person) => <button type="button" key={person.id} className={`iap-face-choice${search.personId === person.id ? ' is-active' : ''}`} onClick={() => { changeSearch({ personId: person.id }); setFaceOpen(false) }} disabled={busy}><Face treeId={treeId} token={token} person={person} /><span>{person.name || t('Sans nom')}</span>{search.personId === person.id && <Check size={15} />}</button>)}
</div>}
{filteredPeople.length > 30 && <p className="pz-small">{t('Précisez un nom pour voir les autres visages.')}</p>}
{people?.length === 0 && <p className="pz-small">{t('Aucune personne dans Immich.')}</p>}
{people?.length > 0 && filteredPeople.length === 0 && <p className="pz-small">{t('Aucune personne trouvée.')}</p>}
{faceError && <p className="pz-error" role="alert">{faceError}</p>}
</div>}
</div>
<div className="iap-results" aria-busy={loading}>
{error && !preview && errorBlock}
{loading ? <div className="iap-empty" role="status"><PzBusy busy busyLabel={t('Chargement…')} /><p>{t('À la recherche de vos souvenirs…')}</p></div> : result?.assets.length ? <div className="iap-grid">{result.assets.map((asset) => <article key={asset.id} className={`iap-asset${selected.includes(asset.id) ? ' is-selected' : ''}`}>
<button type="button" className="iap-open" data-asset-id={asset.id} aria-label={t('Agrandir {name}', { name: asset.name })} onClick={() => { previewTrigger.current = asset.id; setPreview(asset); requestAnimationFrame(() => dialog.current?.focus()) }} disabled={busy}>
<AssetImage treeId={treeId} token={token} asset={asset} /><span className="iap-zoom"><ZoomIn size={17} /></span>{asset.type === 'VIDEO' && <span className="iap-video"><Video size={14} />{t('Vidéo')}</span>}
</button>
<div className="iap-asset-caption"><span title={asset.name}>{asset.name}</span><button type="button" className={`iap-select${selected.includes(asset.id) ? ' is-selected' : ''}`} onClick={() => select(asset)} aria-label={selected.includes(asset.id) ? t('Ajouté') : t('Sélectionner {name}', { name: asset.name })} disabled={busy || selected.includes(asset.id)}><Check size={16} /></button></div>
</article>)}</div> : !error && <div className="iap-empty"><Search size={28} /><p>{t('Aucun média trouvé.')}</p><span className="pz-small">{t('Essayez une autre description ou un autre visage.')}</span></div>}
</div>
{!loading && result && <nav className="iap-pagination" aria-label={t('Pages des souvenirs Immich')}><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || search.page === 1} onClick={() => setSearch((current) => ({ ...current, page: current.page - 1 }))}><ChevronLeft size={17} />{t('Précédent')}</button><span className="pz-small">{t('Page {page}', { page: search.page })}</span><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || !result.hasNextPage} onClick={() => setSearch((current) => ({ ...current, page: current.page + 1 }))}>{t('Suivant')}<ChevronRight size={17} /></button></nav>}
</>}
{error && preview && errorBlock}
<footer className="iap-footer"><p className="pz-small">{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')}<span>{t('Vos médias seront copiés dans Parenthèse.')}</span></p>
{preview ? <button type="button" className="pz-btn pz-btn--primary" onClick={() => select(preview)} disabled={busy || selected.includes(preview.id)}><Check size={17} /><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{selected.includes(preview.id) ? t('Ajouté') : t('Choisir ce souvenir')}</PzBusy></button> : <button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={busy}><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{t('Terminer la sélection')}</PzBusy></button>}
</footer>
</section>
</div>, document.body)
}
@@ -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; }
+114 -33
View File
@@ -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 (
<div className="pz-overlay media-manager-modal-overlay" onClick={handleOverlayClick}>
<div className="pz-modal pz-modal--wide media-manager-modal-card" role="dialog" aria-modal="true" aria-labelledby="mediaManagerTitle">
@@ -174,24 +230,49 @@ const MediaManagerPanel = ({
</div>
)}
{!isCitation && !isYoutubeVideo && (
<div className="pz-field">
<label htmlFor="mediaUploadFile" className="mm-drop">
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
<span className="mm-drop-text">
<strong>{file ? file.name : t('Choisir un fichier')}</strong>
<span>{file ? t('Pour en prendre un autre, touchez ici') : mediaType === 'photo' ? t('5 Mo au maximum') : t('20 Mo au maximum')}</span>
</span>
</label>
<input
id="mediaUploadFile"
className="mm-drop-input"
type="file"
accept={fileAccept}
onChange={(event) => setFile(event.target.files?.[0] || null)}
/>
</div>
)}
{pickingImmich && canManage && <ImmichAssetPicker treeId={treeId} token={token} onClose={() => 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 && <button type="button" className="mm-drop-link" disabled={loading || sending} onClick={() => setPickingImmich(true)}>{t('depuis Immich')}</button>
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 (
<div className="pz-field">
<label htmlFor="mediaUploadFile" className="mm-drop">
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
<span className="mm-drop-text">
<strong>{title}{withImmich && count === 0 && !file && <> {t('ou')} {immichLink}</>}</strong>
<span>{hint}</span>
</span>
</label>
<input
id="mediaUploadFile"
ref={fileInput}
className="mm-drop-input"
type="file"
accept={fileAccept}
onChange={(event) => { setFile(event.target.files?.[0] || null); setImmichFiles([]) }}
/>
{count > 0 && (
<ul className="mm-immich-queue" aria-label={t('Souvenirs choisis dans Immich')}>
{immichFiles.map((item) => (
<li key={item.key}>
<span>{item.file.name}</span>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setImmichFiles((list) => list.filter((entry) => entry.key !== item.key))} disabled={sending}>{t('Retirer')}</button>
</li>
))}
</ul>
)}
</div>
)
})()}
{isYoutubeVideo && (
<div className="pz-field">
@@ -220,7 +301,7 @@ const MediaManagerPanel = ({
)}
<div className={canManage ? 'pz-row2' : undefined}>
<div className="pz-field">
{immichFiles.length === 0 && <div className="pz-field">
<label htmlFor="mediaUploadCaption">{canManage ? t('Titre') : t('Une légende')} <span className="mm-optional">{t('facultatif')}</span></label>
<input
id="mediaUploadCaption"
@@ -229,7 +310,7 @@ const MediaManagerPanel = ({
onChange={(event) => setCaption(event.target.value)}
placeholder={t('Été à Quiberon, 1978')}
/>
</div>
</div>}
{canManage && <div className="pz-field">
<label htmlFor="mediaUploadSource">{t('Source')} <span className="mm-optional">{t('facultatif')}</span></label>
<input
@@ -262,7 +343,7 @@ const MediaManagerPanel = ({
<div className="pz-modal-actions">
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canUpload}>
<Plus size={16} aria-hidden="true" />
<PzBusy busy={loading} busyLabel={t('Envoi du souvenir')}>{t('Ajouter ce souvenir')}</PzBusy>
<PzBusy busy={loading || sending} busyLabel={t('Envoi du souvenir')}>{immichFiles.length > 1 ? t('Ajouter ces {n} souvenirs', { n: immichFiles.length }) : t('Ajouter ce souvenir')}</PzBusy>
</button>
</div>
</form>
+23 -2
View File
@@ -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
</ul>
)}
<div className="ms-file-actions">
{isAdmin && immichConfigured && <button type="button" className="ms-file-add" disabled={busy} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
<button type="button" ref={milestone ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
<Paperclip size={15} strokeWidth={1.8} />
{t('Ajouter un fichier')}
@@ -250,6 +270,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
</button>
)}
</div>
{pickingImmich && <ImmichAssetPicker treeId={treeId} token={token} onSelect={(file) => addFiles([file])} onClose={() => setPickingImmich(false)} />}
<input ref={fileRef} type="file" accept={ACCEPTED_FILES} multiple hidden onChange={(e) => { addFiles(e.target.files || []); e.target.value = '' }} />
{picking && (
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: firstNameLabel(person) })}>
+4 -2
View File
@@ -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
}
}
+47
View File
@@ -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',
+2
View File
@@ -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);