feat: import Immich memories with context and face search and large previews

This commit is contained in:
Askrosm committed 2026-10-19 08:44:15 +02:00
1 parent 2dec945fa3
commit ae77105cd0
12 files changed
+601 -7

No files matched your search

+31 -3
View File
@@ -85,12 +85,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 +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<Buffer | null> {
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<ImmichPersonSummary[]> {
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<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): Promise<Buffer | null> {
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(() => {})
+43
View File
@@ -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.
+108
View File
@@ -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('<html>unsafe</html>'))
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<string> {
await app.ready()
return (app as any).jwt.sign(
+36
View File
@@ -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.
+2
View File
@@ -3010,6 +3010,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}
+23
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,26 @@ 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()
}
// 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) {
+25
View File
@@ -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')
})
})
@@ -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; } }
@@ -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 <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 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(<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>
<p className="pz-small">{t('Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.')}</p>
</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…')} aria-label={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 ${search.personId ? 'pz-btn--soft' : 'pz-btn--secondary'}`} aria-expanded={faceOpen} onClick={() => setFaceOpen((value) => !value)} disabled={busy}><UserRound size={16} />{selectedFace?.name || t('Filtrer par visage')}</button>
{(search.query || search.personId) && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => { setDraft(''); changeSearch({ query: '', personId: '' }) }} disabled={busy}><X size={14} />{t('Effacer les filtres')}</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>}
<p className="pz-hint">{t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')}</p>
</div>
<div className="iap-results" aria-busy={loading}>
{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 && <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>}
<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)
}
+20 -2
View File
@@ -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 = ({
</div>
)}
{canManage && immichConfigured && <button type="button" className="pz-btn pz-btn--soft" disabled={loading} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
{pickingImmich && canManage && <ImmichAssetPicker treeId={treeId} token={token} onClose={() => setPickingImmich(false)} onSelect={(selectedFile) => {
setFile(selectedFile); setMediaType(selectedFile.type.startsWith('video/') ? 'video' : 'photo'); setVideoMode('file'); setPickingImmich(false)
}} />}
{!isCitation && !isYoutubeVideo && (
<div className="pz-field">
<label htmlFor="mediaUploadFile" className="mm-drop">
+23 -2
View File
@@ -7,6 +7,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 ».
@@ -48,6 +50,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('')
@@ -78,14 +89,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]))
@@ -238,6 +257,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')}
@@ -249,6 +269,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: person.firstName })}>
+41
View File
@@ -266,6 +266,47 @@ 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',
'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}',
'{n} souvenir(s) sélectionné(s)': '{n} selected memory/memories',
'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.',
'Les médias sélectionnés seront copiés dans les souvenirs. Photo : 5 Mo maximum ; vidéo : 20 Mo maximum.': 'Selected media will be copied into memories. Photo: up to 5 MB; video: up to 20 MB.',
'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',