mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
feat: import Immich memories with context and face search and large previews
This commit is contained in:
1 parent
2dec945fa3
commit
ae77105cd0
12 files changed
+601
-7
No files matched your search
@@ -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(() => {})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user