mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #128 from IliesAllali/feat/immich-memories-retouches
Souvenirs depuis Immich (#103 d'Askrosm) et retouches de design
This commit is contained in:
15 files changed
+824
-42
No files matched your search
@@ -4,6 +4,7 @@ import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { decryptImmichApiKey } from './immich-secret.js'
|
||||
import { sniffMimeType } from './media-files.js'
|
||||
|
||||
// Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes.
|
||||
// Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 :
|
||||
@@ -85,12 +86,14 @@ export class ImmichClient {
|
||||
return `${this.apiUrl}${path}`
|
||||
}
|
||||
|
||||
private async fetch(path: string): Promise<Response> {
|
||||
private async fetch(path: string, body?: unknown): Promise<Response> {
|
||||
const url = this.buildUrl(path)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { 'x-api-key': this.apiKey, Accept: 'application/json' },
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { 'x-api-key': this.apiKey, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS),
|
||||
// Aucune redirection suivie : la clé ne doit jamais partir vers une autre adresse.
|
||||
redirect: 'error',
|
||||
@@ -126,6 +129,28 @@ export class ImmichClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listAssets(page: number, type: 'IMAGE' | 'VIDEO', filters: { query?: string; personId?: string } = {}) {
|
||||
const query = filters.query?.trim()
|
||||
const response = await this.fetch(query ? '/api/search/smart' : '/api/search/metadata', {
|
||||
page, size: 30, type, isTrashed: false,
|
||||
...(query ? { query } : {}),
|
||||
...(filters.personId ? { personIds: [filters.personId] } : {}),
|
||||
})
|
||||
const payload = await this.readJson(response) as { assets?: { items?: Array<{ id: string; type: string; originalFileName: string }>; nextPage?: string | null } }
|
||||
if (!Array.isArray(payload.assets?.items)) throw new ImmichUnavailableError('Liste de médias Immich illisible')
|
||||
return {
|
||||
assets: payload.assets.items.filter((asset) => isImmichPersonId(asset.id) && asset.type === type).map((asset) => ({
|
||||
id: asset.id, type: asset.type, name: asset.originalFileName,
|
||||
})),
|
||||
hasNextPage: Boolean(payload.assets.nextPage),
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise<Buffer | null> {
|
||||
const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original'
|
||||
return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes, 5 * 1024 * 1024)
|
||||
}
|
||||
|
||||
// Toutes les personnes (sans les masquées), en suivant la pagination d'Immich.
|
||||
async listPeople(): Promise<ImmichPersonSummary[]> {
|
||||
const people: ImmichPersonSummary[] = []
|
||||
@@ -178,9 +203,14 @@ export class ImmichClient {
|
||||
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
|
||||
// Lue en flux : au-delà de maxBytes, la lecture s'arrête et renvoie null sans charger le reste.
|
||||
async getPersonThumbnail(personId: string, maxBytes: number): Promise<Buffer | null> {
|
||||
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`)
|
||||
return this.readBytes(`/api/people/${encodeURIComponent(personId)}/thumbnail`, maxBytes)
|
||||
}
|
||||
|
||||
private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise<Buffer | null> {
|
||||
const response = await this.fetch(path)
|
||||
let limit = response.headers.get('content-type')?.startsWith('image/') ? Math.min(maxBytes, maxImageBytes) : maxBytes
|
||||
const declared = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
if (Number.isFinite(declared) && declared > limit) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
@@ -191,12 +221,19 @@ export class ImmichClient {
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
let signature = Buffer.alloc(0)
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
// Ne pas se fier au Content-Type : même avec une signature répartie sur
|
||||
// plusieurs chunks, une image est bornée à 5 Mo dès ses premiers octets.
|
||||
if (signature.length < 12) {
|
||||
signature = Buffer.concat([signature, Buffer.from(value.subarray(0, 12 - signature.length))])
|
||||
if (sniffMimeType(signature, 'video')?.startsWith('image/')) limit = Math.min(limit, maxImageBytes)
|
||||
}
|
||||
if (total > limit || (Number.isFinite(declared) && declared > limit)) {
|
||||
await reader.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -98,6 +98,49 @@ function buildAvatarRelativePath(treeId: string, personId: string, extension: st
|
||||
}
|
||||
|
||||
export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre.
|
||||
// Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota,
|
||||
// validation et rattachement au moment), sans exposer d'URL ni de clé Immich au navigateur.
|
||||
app.get('/trees/:id/immich/assets', async (request, reply) => {
|
||||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||||
const params = treeParamSchema.safeParse(request.params)
|
||||
const query = z.object({
|
||||
page: z.coerce.number().int().min(1).max(100000).default(1),
|
||||
type: z.enum(['IMAGE', 'VIDEO']).default('IMAGE'),
|
||||
q: z.string().trim().max(500).optional(),
|
||||
personId: z.string().refine(isImmichPersonId).optional(),
|
||||
}).safeParse(request.query)
|
||||
if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||||
try {
|
||||
reply.header('Cache-Control', 'no-store')
|
||||
return reply.send(await client.listAssets(query.data.page, query.data.type, { query: query.data.q, personId: query.data.personId }))
|
||||
} catch (error) { return replyImmichError(request, reply, error) }
|
||||
})
|
||||
|
||||
app.get('/trees/:id/immich/assets/:assetId/:variant', async (request, reply) => {
|
||||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||||
const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||||
try {
|
||||
const thumbnail = params.data.variant !== 'original'
|
||||
const bytes = await client.getAssetBytes(params.data.assetId, thumbnail, (thumbnail ? 5 : 20) * 1024 * 1024, params.data.variant === 'preview')
|
||||
if (!bytes) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||||
const mime = sniffMimeType(bytes, 'video')
|
||||
if (!mime || !(mime.startsWith('image/') || (!thumbnail && mime.startsWith('video/')))) return reply.code(400).send({ error: 'invalid_immich_media' })
|
||||
if (mime.startsWith('image/') && bytes.length > MAX_THUMBNAIL_BYTES) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||||
return reply.header('Content-Type', mime).header('X-Content-Type-Options', 'nosniff').header('Cache-Control', 'no-store').send(bytes)
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichNotFoundError) return reply.code(404).send({ error: 'immich_asset_not_found' })
|
||||
return replyImmichError(request, reply, error)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Connexion Immich de l'arbre : lecture, écriture, suppression. Réservées aux
|
||||
// administrateurs de l'arbre ; la clé d'API ne quitte jamais le serveur.
|
||||
|
||||
|
||||
@@ -65,6 +65,161 @@ function mockImmichPerson(options: { thumbnailPath?: string; thumbnailStatus?: n
|
||||
})
|
||||
}
|
||||
|
||||
describe('Souvenirs Immich', () => {
|
||||
it('combine le contexte et un visage sur la recherche intelligente Immich', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?q=repas%20au%20jardin&personId=${immichPersonId}&page=2`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/smart')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ page: 2, size: 30, type: 'IMAGE', isTrashed: false, query: 'repas au jardin', personIds: [immichPersonId] })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('filtre par visage sans contexte via les métadonnées', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?personId=${immichPersonId}&q=%20%20`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ personIds: [immichPersonId] })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un visage invalide et un contexte excessif avant tout appel distant', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
for (const query of ['personId=invalid', `q=${'x'.repeat(501)}`]) {
|
||||
expect((await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?${query}`, headers: { authorization: `Bearer ${token}` } })).statusCode).toBe(400)
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('charge un aperçu agrandi sans demander le fichier original', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes)))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/preview`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`http://immich.test/api/assets/${immichPersonId}/thumbnail?size=preview`)
|
||||
expect(response.headers['content-type']).toBe('image/jpeg')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('réserve la photothèque aux administrateurs et ne contacte pas Immich en cas de refus', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('pagine les vidéos sans exposer les métadonnées privées', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [{ id: immichPersonId, type: 'VIDEO', originalFileName: 'famille.mp4', originalPath: '/private/video.mp4' }], nextPage: '3' } }))
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets?page=2&type=VIDEO', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ assets: [{ id: immichPersonId, type: 'VIDEO', name: 'famille.mp4' }], hasNextPage: true })
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ page: 2, type: 'VIDEO', size: 30 })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse les originaux trop gros avant de les lire', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(new Response('too large', { headers: { 'content-length': String(21 * 1024 * 1024) } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('sert une photo avec son type réel et refuse le HTML', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes)))
|
||||
const response = await app.inject(options)
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.headers['content-type']).toBe('image/jpeg')
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>unsafe</html>'))
|
||||
expect((await app.inject(options)).statusCode).toBe(400)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it.each(['preview', 'original'])('coupe une image à 5 Mo en flux sur %s, même déclarée vidéo', async (variant) => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const cancel = vi.fn()
|
||||
let sent = 0
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
// La signature JPEG arrive en trois chunks, sans Content-Length.
|
||||
if (sent < 3) controller.enqueue(new Uint8Array(jpegBytes.subarray(sent, ++sent)))
|
||||
else { sent++; controller.enqueue(new Uint8Array(1024 * 1024)) }
|
||||
}, cancel,
|
||||
}, { highWaterMark: 0 })
|
||||
fetchMock.mockResolvedValueOnce(new Response(stream, { headers: { 'content-type': 'video/mp4' } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/${variant}`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(sent).toBe(8) // 3 octets + 5 Mo : aucun chunk supplémentaire lu.
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une image annoncée au-dessus de 5 Mo avant toute lecture', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const pull = vi.fn()
|
||||
const cancel = vi.fn()
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), {
|
||||
headers: { 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) },
|
||||
}))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
expect(pull).not.toHaveBeenCalled()
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('conserve la limite vidéo de 20 Mo au-delà de 5 Mo', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const video = Buffer.alloc(6 * 1024 * 1024)
|
||||
video.set(Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom')]))
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.rawPayload.equals(video)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
|
||||
const video = Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom'), 0, 0, 0, 0])
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
|
||||
const response = await app.inject(options)
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.headers['content-type']).toBe('video/mp4')
|
||||
expect(response.rawPayload).toEqual(video)
|
||||
const cancel = vi.fn()
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) { controller.enqueue(new Uint8Array(21 * 1024 * 1024)) }, cancel,
|
||||
})))
|
||||
expect((await app.inject(options)).statusCode).toBe(413)
|
||||
expect(cancel).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Ajouter des souvenirs depuis Immich
|
||||
|
||||
Les administrateurs d'un arbre disposant d'une connexion Immich peuvent utiliser
|
||||
**Depuis Immich** dans **Ajouter des souvenirs**, ou dans les souvenirs d'un moment
|
||||
de l'onglet **Vie**. Choisir Photos ou Vidéos, parcourir les pages puis sélectionner
|
||||
un média. Dans un moment, plusieurs médias peuvent être sélectionnés avant de
|
||||
terminer la sélection. Enregistrer ensuite le souvenir ou le moment.
|
||||
|
||||
Le sélecteur reprend la présentation de Parenthèse. Cliquer sur une photo ouvre
|
||||
un aperçu agrandi avant de choisir le souvenir. La coche sous chaque vignette
|
||||
permet aussi une sélection rapide. La recherche par contexte utilise la recherche
|
||||
intelligente d'Immich (qui doit être activée et avoir indexé la photothèque).
|
||||
Le filtre par visage utilise les personnes reconnues dans Immich, avec leur
|
||||
portrait et une recherche par nom. Il peut être combiné au contexte. La clé doit
|
||||
aussi disposer de `person.read` pour ce filtre et ses portraits.
|
||||
|
||||
Les fichiers sont copiés dans Parenthèse : ils restent disponibles indépendamment
|
||||
d'Immich après l'enregistrement. Les limites existantes s'appliquent : 5 Mo par
|
||||
photo, 20 Mo par vidéo et quota de l'arbre. Les formats pris en charge sont ceux
|
||||
des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Pour les photos,
|
||||
Parenthèse copie la version preview préparée par Immich, ce qui permet aussi
|
||||
d'importer les HEIC/RAW dont Immich a généré l'aperçu. Le nom du fichier suit
|
||||
le format reçu. Les vidéos conservent leur original, sans transcodage ajouté.
|
||||
|
||||
La clé Immich doit disposer de `asset.read`, `asset.view` et `asset.download`, en
|
||||
plus des permissions utilisées pour les portraits. Contrat :
|
||||
[spécification officielle Immich](https://github.com/immich-app/immich/blob/main/open-api/immich-openapi-specs.json).
|
||||
|
||||
La photothèque est accessible uniquement aux administrateurs de l'arbre. La clé
|
||||
et l'adresse Immich restent côté serveur ; la connexion utilise la liste
|
||||
d'instances autorisées existante. Les téléchargements sont bornés en taille et
|
||||
en durée, les redirections sont refusées et le type est vérifié dans les octets.
|
||||
La persistance utilise l'API d'envoi existante : traitement des images, quota,
|
||||
audit et association à la personne ou au moment. Aucune migration de base requise.
|
||||
|
||||
Cette fonction ne synchronise pas automatiquement les changements Immich et ne
|
||||
déduplique pas les imports entre deux sessions. Une validation avec une instance
|
||||
Immich réelle reste nécessaire avant déploiement.
|
||||
@@ -3312,6 +3312,8 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<MediaManagerPanel
|
||||
treeId={tree.treeContext.treeId}
|
||||
token={tree.treeContext.accessToken || auth.userAuth.token}
|
||||
visible={media.mediaManagerState.visible}
|
||||
person={tree.selectedPerson}
|
||||
medias={media.selectedPersonMedias}
|
||||
|
||||
@@ -12,6 +12,9 @@ function withAuth(options = {}, token) {
|
||||
|
||||
export function getImmichErrorMessage(error, t) {
|
||||
const code = error?.payload?.error || ''
|
||||
if (code === 'immich_asset_too_large') return t('Fichier trop volumineux (photo : 5 Mo, vidéo : 20 Mo).')
|
||||
if (code === 'immich_asset_not_found') return t('Média introuvable dans Immich.')
|
||||
if (code === 'invalid_immich_media') return t('Format de média non pris en charge.')
|
||||
if (code === 'immich_not_configured') return t('Immich n’est pas configuré pour cet arbre.')
|
||||
if (code === 'immich_unreachable') return t('Immich est injoignable.')
|
||||
if (code === 'immich_auth_failed') return t('La clé d’accès à Immich a été refusée.')
|
||||
@@ -26,6 +29,41 @@ export function getImmichErrorMessage(error, t) {
|
||||
return error?.message || t('Erreur inattendue.')
|
||||
}
|
||||
|
||||
export function listImmichAssets(treeId, token, page = 1, type = 'IMAGE', { query, personId, signal } = {}) {
|
||||
const search = new URLSearchParams({ page: String(page), type })
|
||||
if (query?.trim()) search.set('q', query.trim())
|
||||
if (personId) search.set('personId', personId)
|
||||
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assets?${search}`, withAuth({ signal }, token))
|
||||
}
|
||||
|
||||
export async function fetchImmichAsset(treeId, assetId, token, variant = 'thumbnail', signal) {
|
||||
const response = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/immich/assets/${encodeURIComponent(assetId)}/${variant}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }, signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = new Error('immich_asset_failed')
|
||||
error.status = response.status
|
||||
error.payload = await response.json().catch(() => ({}))
|
||||
throw error
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
// Les photos utilisent la version préparée par Immich, y compris les HEIC/RAW.
|
||||
// Le nom suit le format réellement reçu ; les vidéos gardent leur original.
|
||||
export async function downloadImmichMemory(treeId, asset, token, signal) {
|
||||
const photo = asset.type === 'IMAGE'
|
||||
const blob = await fetchImmichAsset(treeId, asset.id, token, photo ? 'preview' : 'original', signal)
|
||||
if (!(photo ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) {
|
||||
const error = new Error('invalid_immich_media')
|
||||
error.payload = { error: 'invalid_immich_media' }
|
||||
throw error
|
||||
}
|
||||
const extension = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }[blob.type]
|
||||
const name = asset.name || asset.id
|
||||
return new File([blob], photo && extension ? `${name.replace(/\.[^.]+$/, '')}${extension}` : name, { type: blob.type })
|
||||
}
|
||||
|
||||
// Connexion Immich de l'arbre : métadonnées sûres (jamais la clé, jamais l'adresse) et instances
|
||||
// autorisées par l'opérateur, nommées par leur libellé, dont le panneau d'administration fait un menu.
|
||||
export async function fetchImmichConfig(treeId, token) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadImmichMemory, fetchImmichAsset, listImmichAssets } from './immichApi.js'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('Recherche et aperçus Immich', () => {
|
||||
it('importe une photo HEIC via la preview et adapte son nom au JPEG reçu', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('jpeg', { headers: { 'content-type': 'image/jpeg' } }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const controller = new AbortController()
|
||||
const file = await downloadImmichMemory('tree', { id: 'photo', name: 'IMG_1234.HEIC', type: 'IMAGE' }, 'token', controller.signal)
|
||||
expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/photo\/preview$/)
|
||||
expect(fetch.mock.calls[0][1].signal).toBe(controller.signal)
|
||||
expect(file.name).toBe('IMG_1234.jpg')
|
||||
expect(file.type).toBe('image/jpeg')
|
||||
expect(await file.text()).toBe('jpeg')
|
||||
})
|
||||
it('importe la vidéo originale sans changer son nom', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('video', { headers: { 'content-type': 'video/quicktime' } }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const file = await downloadImmichMemory('tree', { id: 'video', name: 'film.MOV', type: 'VIDEO' }, 'token')
|
||||
expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/video\/original$/)
|
||||
expect(file.name).toBe('film.MOV')
|
||||
expect(file.type).toBe('video/quicktime')
|
||||
})
|
||||
it('refuse une réponse vidéo pour une photo', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('heic', { headers: { 'content-type': 'video/mp4' } })))
|
||||
await expect(downloadImmichMemory('tree', { id: 'photo', type: 'IMAGE' }, 'token')).rejects.toMatchObject({ payload: { error: 'invalid_immich_media' } })
|
||||
})
|
||||
it('encode le contexte et conserve le visage, la page et l’annulation', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('{"assets":[],"hasNextPage":false}'))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const controller = new AbortController()
|
||||
await listImmichAssets('arbre/1', 'token', 3, 'IMAGE', { query: ' été & mer ', personId: 'face-1', signal: controller.signal })
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
expect(parsed.pathname).toContain('arbre%2F1')
|
||||
expect(Object.fromEntries(parsed.searchParams)).toEqual({ page: '3', type: 'IMAGE', q: 'été & mer', personId: 'face-1' })
|
||||
expect(options.signal).toBe(controller.signal)
|
||||
expect(options.headers.Authorization).toBe('Bearer token')
|
||||
})
|
||||
it('ouvre un aperçu sans télécharger l’original', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('image', { headers: { 'content-type': 'image/jpeg' } }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const blob = await fetchImmichAsset('tree', 'photo', 'token', 'preview')
|
||||
expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/photo\/preview$/)
|
||||
expect(blob.type).toBe('image/jpeg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Galerie Immich dans la DA terracotta des souvenirs. */
|
||||
.iap-overlay { z-index: var(--pz-z-modal-nested); }
|
||||
.pz-modal.iap { width: min(100%, 850px); padding: 0; gap: 0; overflow: auto; }
|
||||
.iap-head { padding: 28px 64px 22px 28px; border-bottom: 1px solid var(--pz-line-soft); }
|
||||
.iap-head .pz-eyebrow { display: flex; align-items: center; gap: 7px; color: var(--pz-a700); }
|
||||
.iap-head .pz-title { margin: 3px 0 5px; }
|
||||
.iap-controls { display: grid; gap: 14px; padding: 22px 28px 18px; background: var(--pz-bg); border-bottom: 1px solid var(--pz-line-soft); }
|
||||
.iap-search { display: flex; align-items: center; gap: 10px; padding: 6px 7px 6px 14px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: 999px; color: var(--pz-faint); }
|
||||
.iap-search:focus-within { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.iap-search > svg { flex: none; }
|
||||
.iap-search input { flex: 1; width: 0; min-width: 0; padding: 6px 0; border: 0; background: transparent; outline: none; color: var(--pz-ink); font: 400 14px/1.5 var(--pz-sans); }
|
||||
.iap-search input::placeholder { color: var(--pz-faint); }
|
||||
.iap-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.iap-types { min-width: 210px; }
|
||||
.iap-types .pz-tab { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
|
||||
.iap-faces { display: grid; gap: 12px; padding: 16px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); }
|
||||
.iap-face-list { display: flex; gap: 8px; flex-wrap: wrap; max-height: 180px; overflow-y: auto; }
|
||||
.iap-face-choice { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; min-height: 44px; padding: 5px 12px 5px 5px; border: 1px solid var(--pz-line); border-radius: 999px; background: var(--pz-surface); color: var(--pz-ink-2); font: 500 13px var(--pz-sans); cursor: pointer; }
|
||||
.iap-face-choice > span:not(.iap-face) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.iap-face-choice.is-active { background: var(--pz-a50); border-color: var(--pz-a300); color: var(--pz-a700); }
|
||||
.iap-face { display: grid; place-items: center; flex: none; width: 30px; height: 30px; overflow: hidden; border-radius: 50%; color: var(--pz-a700); background: var(--pz-a100); }
|
||||
.iap-face img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.iap-results { min-height: 200px; padding: 22px 28px; }
|
||||
.iap-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.iap-asset { min-width: 0; border-radius: var(--pz-r-m); background: var(--pz-surface); box-shadow: 0 0 0 1px var(--pz-line); overflow: hidden; transition: box-shadow 160ms ease; }
|
||||
.iap-asset.is-selected { box-shadow: 0 0 0 2px var(--pz-a500); }
|
||||
.iap-open { position: relative; display: block; width: 100%; padding: 0; border: 0; background: var(--pz-bg); cursor: zoom-in; aspect-ratio: 4/3; }
|
||||
.iap-image { position: relative; display: grid; place-items: center; width: 100%; height: 100%; overflow: hidden; background: var(--pz-line-soft); }
|
||||
.iap-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 220ms var(--pz-ease); }
|
||||
.iap-image img[hidden] { display: none; }
|
||||
.iap-placeholder { display: grid; place-items: center; gap: 6px; color: var(--pz-faint); font: 400 12px var(--pz-sans); }
|
||||
.iap-placeholder .pz-busy { width: 40px; height: 24px; }
|
||||
.iap-zoom { position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--pz-surface); color: var(--pz-ink-2); box-shadow: var(--pz-shadow); }
|
||||
.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: var(--pz-media-badge-bg); color: var(--pz-surface); font: 500 12px var(--pz-sans); }
|
||||
.iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 2px 2px 2px 10px; }
|
||||
.iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12.5px/1.4 var(--pz-sans); color: var(--pz-muted); }
|
||||
/* Zone de toucher de 44px, pastille visuelle de 30px centrée dedans */
|
||||
.iap-select { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; background: transparent; color: var(--pz-faint); border: 0; border-radius: 50%; cursor: pointer; }
|
||||
.iap-select::before { content: ""; position: absolute; top: 50%; left: 50%; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--pz-surface); border: 1px solid var(--pz-line); transition: background-color 160ms ease, border-color 160ms ease; }
|
||||
.iap-select > svg { position: relative; }
|
||||
.iap-select.is-selected { color: var(--pz-surface); }
|
||||
.iap-select.is-selected::before { background: var(--pz-a500); border-color: var(--pz-a500); }
|
||||
.iap-open:disabled, .iap-select:disabled, .iap-face-choice:disabled { cursor: default; }
|
||||
.iap-select:disabled:not(.is-selected) { opacity: .45; }
|
||||
.iap :is(.iap-open, .iap-select, .iap-face-choice, .pz-tab):focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -3px; }
|
||||
.iap-clear-label { display: inline; }
|
||||
.iap-empty { display: grid; justify-items: center; align-content: center; min-height: 210px; gap: 12px; color: var(--pz-faint); text-align: center; }
|
||||
.iap-empty > p { margin: 0; font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink-2); }
|
||||
.iap-empty .pz-busy { width: 40px; height: 24px; }
|
||||
.iap-pagination { display: flex; align-items: center; justify-content: space-between; padding: 0 22px 18px; }
|
||||
.iap-footer { position: sticky; bottom: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding: 18px 28px; background: var(--pz-surface); border-top: 1px solid var(--pz-line); z-index: 2; }
|
||||
.iap-footer > p span { display: block; color: var(--pz-faint); font-size: 12.5px; }
|
||||
.iap-error { padding: 0 28px 14px; }
|
||||
.iap-results > .iap-error { padding: 0 0 14px; }
|
||||
.iap-error .pz-error { margin: 0; }
|
||||
.iap-preview-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; }
|
||||
.iap-preview-stage { height: min(56dvh, 520px); margin: 0 20px; overflow: hidden; border-radius: var(--pz-r-m); background: var(--pz-ink); }
|
||||
.iap-image--large { background: var(--pz-ink); }
|
||||
.iap-image--large img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.iap-preview-caption { display: grid; gap: 4px; padding: 14px 28px 20px; }
|
||||
.iap-preview-caption > p { margin: 0; font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink); overflow-wrap: anywhere; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.iap-open:hover .iap-image img { transform: scale(1.035); }
|
||||
.iap-asset:hover { box-shadow: 0 0 0 1px var(--pz-a300), var(--pz-shadow); }
|
||||
.iap-select:hover:not(:disabled) { color: var(--pz-a700); }
|
||||
.iap-select:hover:not(:disabled):not(.is-selected)::before { background: var(--pz-a50); border-color: var(--pz-a300); }
|
||||
.iap-face-choice:hover:not(:disabled) { background: var(--pz-a50); color: var(--pz-a700); border-color: var(--pz-a300); }
|
||||
}
|
||||
@media (max-width: 640px) { .iap-grid { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; } }
|
||||
@media (max-width: 560px) {
|
||||
.pz-modal.iap { max-height: 94dvh; border-radius: var(--pz-r-l) var(--pz-r-l) 0 0; }
|
||||
.iap-head { padding: 24px 54px 18px 20px; }
|
||||
.iap-head .pz-title { font-size: 26px; }
|
||||
.iap-controls { padding: 18px 20px; gap: 12px; }
|
||||
.iap-search { border-radius: var(--pz-r-m); flex-wrap: wrap; }
|
||||
.iap-search input { font-size: 16px; }
|
||||
.iap-search .pz-btn { min-height: 38px; }
|
||||
.iap-results { min-height: 0; padding: 18px 20px; }
|
||||
.iap-results > .iap-error { padding: 0 0 14px; }
|
||||
/* Vidéos/Photos et filtre visage sur une seule ligne */
|
||||
.iap-filters { flex-wrap: wrap; gap: 6px; }
|
||||
.iap-types { flex: 1 1 0; min-width: 0; }
|
||||
.iap-types .pz-tab { padding: 0 8px; }
|
||||
.iap-types .pz-tab svg, .iap-clear-label { display: none; }
|
||||
.iap-face-btn { flex: 0 1 auto; min-width: 0; max-width: 100%; }
|
||||
.iap-face-btn > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.iap-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.iap-footer { padding: 16px 20px calc(16px + env(safe-area-inset-bottom)); gap: 10px; }
|
||||
.iap-footer > .pz-btn { flex: 1; }
|
||||
.iap-footer > p { width: 100%; }
|
||||
.iap-preview-stage { height: 48dvh; margin: 0 12px; }
|
||||
.iap-preview-caption { padding: 14px 20px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .iap-asset, .iap-image img { transition: none; } }
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowLeft, Check, ChevronLeft, ChevronRight, Image, Search, Sparkles, UserRound, Video, X, ZoomIn } from 'lucide-react'
|
||||
import { downloadImmichMemory, fetchImmichAsset, fetchImmichThumbnail, getImmichErrorMessage, listImmichAssets, listImmichPeople } from '../api/immichApi.js'
|
||||
import { t } from '../i18n/index.js'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import './ImmichAssetPicker.css'
|
||||
|
||||
function AssetImage({ treeId, token, asset, large = false }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
const imageRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let objectUrl, observer
|
||||
setUrl(''); setFailed(false)
|
||||
const load = () => {
|
||||
observer?.disconnect()
|
||||
fetchImmichAsset(treeId, asset.id, token, large ? 'preview' : 'thumbnail', controller.signal).then((blob) => {
|
||||
if (!controller.signal.aborted) { objectUrl = URL.createObjectURL(blob); setUrl(objectUrl) }
|
||||
}).catch(() => { if (!controller.signal.aborted) setFailed(true) })
|
||||
}
|
||||
if (large || typeof IntersectionObserver === 'undefined') load()
|
||||
else {
|
||||
observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) load() }, { rootMargin: '100px' })
|
||||
observer.observe(imageRef.current)
|
||||
}
|
||||
return () => { controller.abort(); observer?.disconnect(); if (objectUrl) URL.revokeObjectURL(objectUrl) }
|
||||
}, [treeId, token, asset.id, large])
|
||||
return <span ref={imageRef} className={`iap-image${large ? ' iap-image--large' : ''}`}>
|
||||
{url && <img src={url} alt={large ? asset.name : ''} onError={() => setFailed(true)} hidden={failed} />}
|
||||
{(!url || failed) && <span className="iap-placeholder">{failed ? <><Image size={26} /><span>{t('Aperçu indisponible')}</span></> : <PzBusy busy busyLabel={t('Chargement…')} />}</span>}
|
||||
</span>
|
||||
}
|
||||
|
||||
function Face({ treeId, token, person }) {
|
||||
const [url, setUrl] = useState('')
|
||||
useEffect(() => {
|
||||
let cancelled = false, objectUrl
|
||||
if (person.hasThumbnail) fetchImmichThumbnail(treeId, person.id, token).then((value) => {
|
||||
objectUrl = value
|
||||
if (cancelled) URL.revokeObjectURL(value)
|
||||
else setUrl(value)
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl) }
|
||||
}, [treeId, token, person.id, person.hasThumbnail])
|
||||
return <span className="iap-face" aria-hidden="true">{url ? <img src={url} alt="" /> : <UserRound size={18} />}</span>
|
||||
}
|
||||
|
||||
export default function ImmichAssetPicker({ treeId, token, onSelect, onClose }) {
|
||||
const uid = useId()
|
||||
const [search, setSearch] = useState({ page: 1, type: 'IMAGE', query: '', personId: '' })
|
||||
const [draft, setDraft] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [selected, setSelected] = useState([])
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [people, setPeople] = useState(null)
|
||||
const [faceOpen, setFaceOpen] = useState(false)
|
||||
const [faceQuery, setFaceQuery] = useState('')
|
||||
const [faceError, setFaceError] = useState('')
|
||||
const download = useRef(null), dialog = useRef(null), searchInput = useRef(null)
|
||||
const previewTrigger = useRef(null)
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement
|
||||
searchInput.current?.focus()
|
||||
return () => { download.current?.abort(); previous?.focus() }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setLoading(true); setError(''); setResult(null)
|
||||
listImmichAssets(treeId, token, search.page, search.type, { ...search, signal: controller.signal }).then((data) => {
|
||||
if (!controller.signal.aborted) setResult(data)
|
||||
}).catch((err) => { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) }).finally(() => { if (!controller.signal.aborted) setLoading(false) })
|
||||
return () => controller.abort()
|
||||
}, [treeId, token, search])
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listImmichPeople(treeId, token).then((data) => { if (!cancelled) setPeople(data) }).catch((err) => {
|
||||
if (!cancelled) { setPeople([]); setFaceError(getImmichErrorMessage(err, t)) }
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [treeId, token])
|
||||
const changeSearch = (values) => setSearch((current) => ({ ...current, ...values, page: 1 }))
|
||||
const submitSearch = () => changeSearch({ query: draft.trim() })
|
||||
const selectedFace = people?.find((person) => person.id === search.personId)
|
||||
const filteredPeople = (people || []).filter((person) => !faceQuery.trim() || person.name.toLocaleLowerCase().includes(faceQuery.trim().toLocaleLowerCase()))
|
||||
const closePreview = () => { setPreview(null); requestAnimationFrame(() => dialog.current?.querySelector(`[data-asset-id="${previewTrigger.current}"]`)?.focus()) }
|
||||
const handleKey = (event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') { event.preventDefault(); if (!busy) { if (preview) closePreview(); else onClose() } }
|
||||
if (event.key === 'Tab') {
|
||||
const elements = [...dialog.current.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex="0"]')].filter((element) => element.getClientRects().length)
|
||||
const first = elements[0], last = elements.at(-1)
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { event.preventDefault(); last?.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||
}
|
||||
}
|
||||
const select = async (asset) => {
|
||||
if (busy || selected.includes(asset.id)) return
|
||||
setBusy(true); setError('')
|
||||
const controller = new AbortController()
|
||||
download.current = controller
|
||||
try {
|
||||
const file = await downloadImmichMemory(treeId, asset, token, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
onSelect(file)
|
||||
if (!controller.signal.aborted) setSelected((ids) => [...ids, asset.id])
|
||||
} catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) }
|
||||
finally { if (!controller.signal.aborted) setBusy(false) }
|
||||
}
|
||||
// Erreur Immich : affichée en haut des résultats (visible sans défiler sur téléphone), ou sous l'aperçu
|
||||
const errorBlock = <div className="iap-error"><p className="pz-error" role="alert">{error}</p>{!preview && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || loading} onClick={() => setSearch((current) => ({ ...current }))}>{t('Réessayer')}</button>}</div>
|
||||
return createPortal(<div className="pz-overlay iap-overlay" onClick={(event) => event.stopPropagation()} onMouseDown={(event) => { if (event.target === event.currentTarget && !busy) onClose() }} onKeyDown={handleKey}>
|
||||
<section ref={dialog} className="pz-modal iap" role="dialog" aria-modal="true" aria-labelledby={`${uid}-title`} tabIndex={-1}>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')} disabled={busy}><X size={18} /></button>
|
||||
<header className="pz-modal-head iap-head">
|
||||
<p className="pz-eyebrow"><Sparkles size={14} />{t('Depuis Immich')}</p>
|
||||
<h2 id={`${uid}-title`} className="pz-title">{preview ? t('Un souvenir à regarder de plus près') : t('Retrouver vos souvenirs')}</h2>
|
||||
</header>
|
||||
{preview ? <>
|
||||
<div className="iap-preview-toolbar"><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={closePreview} disabled={busy}><ArrowLeft size={16} />{t('Retour aux photos')}</button><span className="pz-tag">{preview.type === 'VIDEO' ? t('Vidéo') : t('Photo')}</span></div>
|
||||
<div className="iap-preview-stage"><AssetImage key={preview.id} treeId={treeId} token={token} asset={preview} large /></div>
|
||||
<div className="iap-preview-caption"><p>{preview.name}</p>{preview.type === 'VIDEO' && <span className="pz-small">{t('Aperçu de la vidéo')}</span>}</div>
|
||||
</> : <>
|
||||
<div className="iap-controls">
|
||||
<div className="iap-search" role="search" aria-label={t('Recherche dans Immich')}>
|
||||
<Search size={18} aria-hidden="true" /><input ref={searchInput} value={draft} maxLength={500} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitSearch() } }} placeholder={t('Un repas au jardin, des vacances à la mer…')} title={t('La recherche par contexte utilise la recherche intelligente activée dans Immich.')} aria-label={search.type === 'VIDEO' ? t('Décrire la vidéo recherchée') : t('Décrire la photo recherchée')} disabled={busy} />
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={submitSearch} disabled={busy}>{t('Rechercher')}</button>
|
||||
</div>
|
||||
<div className="iap-filters">
|
||||
<div className="pz-tabs iap-types" role="group" aria-label={t('Type')}>
|
||||
{[['IMAGE', t('Photos'), Image], ['VIDEO', t('Vidéos'), Video]].map(([value, label, Icon]) => <button key={value} type="button" className={`pz-tab${search.type === value ? ' is-active' : ''}`} aria-pressed={search.type === value} onClick={() => changeSearch({ type: value })} disabled={busy}><Icon size={15} />{label}</button>)}
|
||||
</div>
|
||||
<button type="button" className={`pz-btn pz-btn--sm iap-face-btn ${search.personId ? 'pz-btn--soft' : 'pz-btn--secondary'}`} aria-expanded={faceOpen} onClick={() => setFaceOpen((value) => !value)} disabled={busy}><UserRound size={16} /><span>{selectedFace?.name || t('Filtrer par visage')}</span></button>
|
||||
{(search.query || search.personId) && <button type="button" className="pz-btn pz-btn--ghost pz-btn--sm iap-clear" aria-label={t('Effacer les filtres')} title={t('Effacer les filtres')} onClick={() => { setDraft(''); changeSearch({ query: '', personId: '' }) }} disabled={busy}><X size={14} /><span className="iap-clear-label">{t('Effacer les filtres')}</span></button>}
|
||||
</div>
|
||||
{faceOpen && <div className="iap-faces">
|
||||
<label className="pz-field"><span>{t('Personne reconnue dans Immich')}</span><input value={faceQuery} onChange={(event) => setFaceQuery(event.target.value)} placeholder={t('Rechercher par nom')} disabled={busy} /></label>
|
||||
{people === null ? <p className="pz-small" role="status">{t('Chargement…')}</p> : <div className="iap-face-list">
|
||||
<button type="button" className={`iap-face-choice${!search.personId ? ' is-active' : ''}`} onClick={() => { changeSearch({ personId: '' }); setFaceOpen(false) }} disabled={busy}><span className="iap-face"><UserRound size={18} /></span>{t('Tous les visages')}</button>
|
||||
{filteredPeople.slice(0, 30).map((person) => <button type="button" key={person.id} className={`iap-face-choice${search.personId === person.id ? ' is-active' : ''}`} onClick={() => { changeSearch({ personId: person.id }); setFaceOpen(false) }} disabled={busy}><Face treeId={treeId} token={token} person={person} /><span>{person.name || t('Sans nom')}</span>{search.personId === person.id && <Check size={15} />}</button>)}
|
||||
</div>}
|
||||
{filteredPeople.length > 30 && <p className="pz-small">{t('Précisez un nom pour voir les autres visages.')}</p>}
|
||||
{people?.length === 0 && <p className="pz-small">{t('Aucune personne dans Immich.')}</p>}
|
||||
{people?.length > 0 && filteredPeople.length === 0 && <p className="pz-small">{t('Aucune personne trouvée.')}</p>}
|
||||
{faceError && <p className="pz-error" role="alert">{faceError}</p>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="iap-results" aria-busy={loading}>
|
||||
{error && !preview && errorBlock}
|
||||
{loading ? <div className="iap-empty" role="status"><PzBusy busy busyLabel={t('Chargement…')} /><p>{t('À la recherche de vos souvenirs…')}</p></div> : result?.assets.length ? <div className="iap-grid">{result.assets.map((asset) => <article key={asset.id} className={`iap-asset${selected.includes(asset.id) ? ' is-selected' : ''}`}>
|
||||
<button type="button" className="iap-open" data-asset-id={asset.id} aria-label={t('Agrandir {name}', { name: asset.name })} onClick={() => { previewTrigger.current = asset.id; setPreview(asset); requestAnimationFrame(() => dialog.current?.focus()) }} disabled={busy}>
|
||||
<AssetImage treeId={treeId} token={token} asset={asset} /><span className="iap-zoom"><ZoomIn size={17} /></span>{asset.type === 'VIDEO' && <span className="iap-video"><Video size={14} />{t('Vidéo')}</span>}
|
||||
</button>
|
||||
<div className="iap-asset-caption"><span title={asset.name}>{asset.name}</span><button type="button" className={`iap-select${selected.includes(asset.id) ? ' is-selected' : ''}`} onClick={() => select(asset)} aria-label={selected.includes(asset.id) ? t('Ajouté') : t('Sélectionner {name}', { name: asset.name })} disabled={busy || selected.includes(asset.id)}><Check size={16} /></button></div>
|
||||
</article>)}</div> : !error && <div className="iap-empty"><Search size={28} /><p>{t('Aucun média trouvé.')}</p><span className="pz-small">{t('Essayez une autre description ou un autre visage.')}</span></div>}
|
||||
</div>
|
||||
{!loading && result && <nav className="iap-pagination" aria-label={t('Pages des souvenirs Immich')}><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || search.page === 1} onClick={() => setSearch((current) => ({ ...current, page: current.page - 1 }))}><ChevronLeft size={17} />{t('Précédent')}</button><span className="pz-small">{t('Page {page}', { page: search.page })}</span><button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" disabled={busy || !result.hasNextPage} onClick={() => setSearch((current) => ({ ...current, page: current.page + 1 }))}>{t('Suivant')}<ChevronRight size={17} /></button></nav>}
|
||||
</>}
|
||||
{error && preview && errorBlock}
|
||||
<footer className="iap-footer"><p className="pz-small">{selected.length === 1 ? t('1 souvenir sélectionné') : selected.length > 1 ? t('{n} souvenirs sélectionnés', { n: selected.length }) : t('Photo : 5 Mo · Vidéo : 20 Mo')}<span>{t('Vos médias seront copiés dans Parenthèse.')}</span></p>
|
||||
{preview ? <button type="button" className="pz-btn pz-btn--primary" onClick={() => select(preview)} disabled={busy || selected.includes(preview.id)}><Check size={17} /><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{selected.includes(preview.id) ? t('Ajouté') : t('Choisir ce souvenir')}</PzBusy></button> : <button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={busy}><PzBusy busy={busy} busyLabel={t('Téléchargement…')}>{t('Terminer la sélection')}</PzBusy></button>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>, document.body)
|
||||
}
|
||||
@@ -37,6 +37,11 @@
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .mm-drop:hover { background: var(--pz-a100); } }
|
||||
.mm-drop-input:focus-visible + .mm-drop { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
.mm-drop-link { padding: 0; border: 0; background: none; cursor: pointer; font: inherit; font-weight: 500; color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.mm-drop-link:disabled { cursor: default; opacity: .6; }
|
||||
.mm-immich-queue { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.mm-immich-queue li { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.mm-immich-queue span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); }
|
||||
.mm-drop-icon { width: 40px; height: 40px; flex: none; display: grid; place-items: center; border-radius: 12px; background: var(--pz-surface); color: var(--pz-a700); }
|
||||
.mm-drop-text { display: grid; gap: 2px; min-width: 0; }
|
||||
.mm-drop-text strong { font: 500 14px var(--pz-sans); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { readContributorName } from '../utils/contributorName'
|
||||
import { ArrowDown, ArrowUp, FileText, Image, MapPin, Mic, Plus, Quote, Route, Trash2, Upload, Video, X } from 'lucide-react'
|
||||
import { getAcceptForMediaType } from '../utils/mediaUpload'
|
||||
@@ -6,6 +6,8 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
import './MediaManagerPanel.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import ImmichAssetPicker from './ImmichAssetPicker.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
{ value: 'photo', label: t('Photo'), Icon: Image },
|
||||
@@ -32,6 +34,8 @@ const MediaManagerPanel = ({
|
||||
errorMessage,
|
||||
notice = '',
|
||||
canManage = true,
|
||||
treeId,
|
||||
token,
|
||||
onClose,
|
||||
onUpload,
|
||||
onMove,
|
||||
@@ -40,6 +44,21 @@ const MediaManagerPanel = ({
|
||||
const [caption, setCaption] = useState('')
|
||||
const [source, setSource] = useState('')
|
||||
const [file, setFile] = useState(null)
|
||||
const [immichConfigured, setImmichConfigured] = useState(false)
|
||||
const [pickingImmich, setPickingImmich] = useState(false)
|
||||
// Souvenirs choisis dans Immich : un souvenir par fichier, envoyés l'un après l'autre à l'enregistrement
|
||||
const [immichFiles, setImmichFiles] = useState([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const fileInput = useRef(null)
|
||||
const immichAvailable = canManage && immichConfigured
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (visible && canManage && treeId && token) fetchImmichConfig(treeId, token).then(({ config }) => {
|
||||
if (!cancelled) setImmichConfigured(Boolean(config?.configured))
|
||||
}).catch(() => { if (!cancelled) setImmichConfigured(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [visible, canManage, treeId, token])
|
||||
useEffect(() => { if (!visible) { setPickingImmich(false); setImmichConfigured(false); setImmichFiles([]) } }, [visible])
|
||||
const [mediaType, setMediaType] = useState('photo')
|
||||
const [citationText, setCitationText] = useState('')
|
||||
const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube'
|
||||
@@ -53,7 +72,7 @@ const MediaManagerPanel = ({
|
||||
const isCitation = mediaType === 'citation'
|
||||
const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube'
|
||||
const canUpload = useMemo(() => {
|
||||
if (loading) {
|
||||
if (loading || pickingImmich || sending) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -61,6 +80,10 @@ const MediaManagerPanel = ({
|
||||
return false
|
||||
}
|
||||
|
||||
if (immichFiles.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isCitation) {
|
||||
return citationText.trim().length > 0
|
||||
}
|
||||
@@ -70,7 +93,7 @@ const MediaManagerPanel = ({
|
||||
}
|
||||
|
||||
return Boolean(file)
|
||||
}, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, youtubeUrl])
|
||||
}, [canManage, citationText, contributorName, file, immichFiles, isCitation, isYoutubeVideo, loading, pickingImmich, sending, youtubeUrl])
|
||||
const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType])
|
||||
|
||||
if (!visible || !person) {
|
||||
@@ -83,16 +106,39 @@ const MediaManagerPanel = ({
|
||||
return
|
||||
}
|
||||
|
||||
await onUpload({
|
||||
file,
|
||||
caption: caption.trim() || null,
|
||||
source: source.trim() || null,
|
||||
mediaType,
|
||||
citationText: citationText.trim(),
|
||||
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
|
||||
submittedByLabel: canManage ? null : contributorName.trim(),
|
||||
})
|
||||
if (immichFiles.length > 0) {
|
||||
// Un souvenir par fichier Immich, titré par son nom de fichier ; on s'arrête à la première erreur
|
||||
setSending(true)
|
||||
let left = immichFiles
|
||||
for (const item of immichFiles) {
|
||||
const ok = await onUpload({
|
||||
file: item.file,
|
||||
caption: item.file.name.replace(/\.[^.]+$/, '') || null,
|
||||
source: source.trim() || null,
|
||||
mediaType: item.file.type.startsWith('video/') ? 'video' : 'photo',
|
||||
citationText: '',
|
||||
youtubeUrl: null,
|
||||
submittedByLabel: canManage ? null : contributorName.trim(),
|
||||
})
|
||||
if (ok === false) break
|
||||
left = left.filter((entry) => entry.key !== item.key)
|
||||
setImmichFiles(left)
|
||||
}
|
||||
setSending(false)
|
||||
if (left.length > 0) return
|
||||
} else {
|
||||
await onUpload({
|
||||
file,
|
||||
caption: caption.trim() || null,
|
||||
source: source.trim() || null,
|
||||
mediaType,
|
||||
citationText: citationText.trim(),
|
||||
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
|
||||
submittedByLabel: canManage ? null : contributorName.trim(),
|
||||
})
|
||||
}
|
||||
setFile(null)
|
||||
setImmichFiles([])
|
||||
setCaption('')
|
||||
setSource('')
|
||||
setMediaType('photo')
|
||||
@@ -127,11 +173,21 @@ const MediaManagerPanel = ({
|
||||
const changeType = (value) => {
|
||||
setMediaType(value)
|
||||
setFile(null)
|
||||
setImmichFiles([])
|
||||
setCitationText('')
|
||||
setVideoMode('file')
|
||||
setYoutubeUrl('')
|
||||
}
|
||||
|
||||
// Un souvenir choisi dans Immich s'ajoute à la liste ; la galerie reste ouverte pour en cocher d'autres
|
||||
const addImmichFile = (picked) => {
|
||||
setFile(null)
|
||||
if (fileInput.current) fileInput.current.value = ''
|
||||
setImmichFiles((list) => (list.some((entry) => entry.file.name === picked.name && entry.file.size === picked.size)
|
||||
? list
|
||||
: [...list, { key: `${picked.name}-${picked.size}`, file: picked }]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pz-overlay media-manager-modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="pz-modal pz-modal--wide media-manager-modal-card" role="dialog" aria-modal="true" aria-labelledby="mediaManagerTitle">
|
||||
@@ -174,24 +230,49 @@ const MediaManagerPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCitation && !isYoutubeVideo && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="mediaUploadFile" className="mm-drop">
|
||||
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
|
||||
<span className="mm-drop-text">
|
||||
<strong>{file ? file.name : t('Choisir un fichier')}</strong>
|
||||
<span>{file ? t('Pour en prendre un autre, touchez ici') : mediaType === 'photo' ? t('5 Mo au maximum') : t('20 Mo au maximum')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="mediaUploadFile"
|
||||
className="mm-drop-input"
|
||||
type="file"
|
||||
accept={fileAccept}
|
||||
onChange={(event) => setFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pickingImmich && canManage && <ImmichAssetPicker treeId={treeId} token={token} onClose={() => setPickingImmich(false)} onSelect={addImmichFile} />}
|
||||
{!isCitation && !isYoutubeVideo && (() => {
|
||||
// Immich ne donne que des photos et des vidéos : le lien n'apparaît que pour ces deux types
|
||||
const withImmich = immichAvailable && (mediaType === 'photo' || mediaType === 'video')
|
||||
// Le lien est un bouton dans le label : un clic dessus n'ouvre pas le sélecteur de fichiers
|
||||
const immichLink = withImmich && <button type="button" className="mm-drop-link" disabled={loading || sending} onClick={() => setPickingImmich(true)}>{t('depuis Immich')}</button>
|
||||
const count = immichFiles.length
|
||||
const title = count === 1 ? t('1 souvenir choisi dans Immich')
|
||||
: count > 1 ? t('{n} souvenirs choisis dans Immich', { n: count })
|
||||
: file ? file.name : t('Choisir un fichier')
|
||||
const hint = count > 0 ? <>{t('En ajouter d’autres')} {immichLink}</>
|
||||
: file ? t('Pour en prendre un autre, touchez ici')
|
||||
: mediaType === 'photo' ? t('5 Mo au maximum') : t('20 Mo au maximum')
|
||||
return (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="mediaUploadFile" className="mm-drop">
|
||||
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
|
||||
<span className="mm-drop-text">
|
||||
<strong>{title}{withImmich && count === 0 && !file && <> {t('ou')} {immichLink}</>}</strong>
|
||||
<span>{hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="mediaUploadFile"
|
||||
ref={fileInput}
|
||||
className="mm-drop-input"
|
||||
type="file"
|
||||
accept={fileAccept}
|
||||
onChange={(event) => { setFile(event.target.files?.[0] || null); setImmichFiles([]) }}
|
||||
/>
|
||||
{count > 0 && (
|
||||
<ul className="mm-immich-queue" aria-label={t('Souvenirs choisis dans Immich')}>
|
||||
{immichFiles.map((item) => (
|
||||
<li key={item.key}>
|
||||
<span>{item.file.name}</span>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setImmichFiles((list) => list.filter((entry) => entry.key !== item.key))} disabled={sending}>{t('Retirer')}</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isYoutubeVideo && (
|
||||
<div className="pz-field">
|
||||
@@ -220,7 +301,7 @@ const MediaManagerPanel = ({
|
||||
)}
|
||||
|
||||
<div className={canManage ? 'pz-row2' : undefined}>
|
||||
<div className="pz-field">
|
||||
{immichFiles.length === 0 && <div className="pz-field">
|
||||
<label htmlFor="mediaUploadCaption">{canManage ? t('Titre') : t('Une légende')} <span className="mm-optional">{t('facultatif')}</span></label>
|
||||
<input
|
||||
id="mediaUploadCaption"
|
||||
@@ -229,7 +310,7 @@ const MediaManagerPanel = ({
|
||||
onChange={(event) => setCaption(event.target.value)}
|
||||
placeholder={t('Été à Quiberon, 1978')}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
{canManage && <div className="pz-field">
|
||||
<label htmlFor="mediaUploadSource">{t('Source')} <span className="mm-optional">{t('facultatif')}</span></label>
|
||||
<input
|
||||
@@ -262,7 +343,7 @@ const MediaManagerPanel = ({
|
||||
<div className="pz-modal-actions">
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canUpload}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
<PzBusy busy={loading} busyLabel={t('Envoi du souvenir')}>{t('Ajouter ce souvenir')}</PzBusy>
|
||||
<PzBusy busy={loading || sending} busyLabel={t('Envoi du souvenir')}>{immichFiles.length > 1 ? t('Ajouter ces {n} souvenirs', { n: immichFiles.length }) : t('Ajouter ce souvenir')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js'
|
||||
import { readContributorName, saveContributorName } from '../utils/contributorName.js'
|
||||
import './MomentSheet.css'
|
||||
import ImmichAssetPicker from './ImmichAssetPicker.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
|
||||
// Fiche « Ajouter un moment » de l'onglet Vie : quoi, quand, où, et des souvenirs (photo, vidéo, acte en
|
||||
// PDF, enregistrement). La date exacte, la fin, la source et la note restent dans « Plus de détails ».
|
||||
@@ -49,6 +51,15 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
const [linkedIds, setLinkedIds] = useState(() => (entry?.mediaIds || []).map(String))
|
||||
const [files, setFiles] = useState([])
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [immichConfigured, setImmichConfigured] = useState(false)
|
||||
const [pickingImmich, setPickingImmich] = useState(false)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (isAdmin) fetchImmichConfig(treeId, token).then(({ config }) => {
|
||||
if (!cancelled) setImmichConfigured(Boolean(config?.configured))
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [isAdmin, treeId, token])
|
||||
const [name, setName] = useState(() => readContributorName() || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -79,14 +90,22 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
const pickable = medias.filter((m) => m.type !== 'citation')
|
||||
const yearValid = !year || /^\d{4}$/.test(year)
|
||||
const endValid = !endYear || (/^\d{4}$/.test(endYear) && (!year || Number(endYear) >= Number(year)))
|
||||
const canSave = (milestone || what.trim().length > 0) && yearValid && endValid && !busy
|
||||
const canSave = (milestone || what.trim().length > 0) && yearValid && endValid && !busy && !pickingImmich
|
||||
|
||||
const addFiles = (list) => {
|
||||
const next = [...list].map((file) => {
|
||||
const type = inferMediaTypeFromMimeType(file.type)
|
||||
return { key: `${file.name}-${file.size}-${file.lastModified}`, file, type, preview: type === 'photo' ? URL.createObjectURL(file) : '' }
|
||||
})
|
||||
setFiles((current) => [...current, ...next.filter((n) => !current.some((c) => c.key === n.key))])
|
||||
setFiles((current) => {
|
||||
const merged = [...current]
|
||||
for (const item of next) {
|
||||
if (merged.some((existing) => existing.key === item.key)) {
|
||||
if (item.preview) URL.revokeObjectURL(item.preview)
|
||||
} else merged.push(item)
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
const toggleLinked = (id) => setLinkedIds((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]))
|
||||
|
||||
@@ -239,6 +258,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
</ul>
|
||||
)}
|
||||
<div className="ms-file-actions">
|
||||
{isAdmin && immichConfigured && <button type="button" className="ms-file-add" disabled={busy} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
|
||||
<button type="button" ref={milestone ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
|
||||
<Paperclip size={15} strokeWidth={1.8} />
|
||||
{t('Ajouter un fichier')}
|
||||
@@ -250,6 +270,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pickingImmich && <ImmichAssetPicker treeId={treeId} token={token} onSelect={(file) => addFiles([file])} onClose={() => setPickingImmich(false)} />}
|
||||
<input ref={fileRef} type="file" accept={ACCEPTED_FILES} multiple hidden onChange={(e) => { addFiles(e.target.files || []); e.target.value = '' }} />
|
||||
{picking && (
|
||||
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: firstNameLabel(person) })}>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useMediaManager({
|
||||
submittedByLabel = null,
|
||||
}) => {
|
||||
if (!canUploadMedia || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Prénom de la personne qui propose (famille), retenu pour la fois suivante
|
||||
@@ -105,7 +105,7 @@ export function useMediaManager({
|
||||
loading: false,
|
||||
error: validationError,
|
||||
}))
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setMediaManagerState((current) => ({
|
||||
@@ -157,12 +157,14 @@ export function useMediaManager({
|
||||
error: '',
|
||||
notice: noticeFor(created),
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
setMediaManagerState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: getAccountErrorMessage(error),
|
||||
}))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,53 @@ export default {
|
||||
"L'ordre n'a pas pu être enregistré. Réessayez.": 'The order could not be saved. Please try again.',
|
||||
// Visages venus d'Immich (PersonCard, ImmichFacePanel, immichApi)
|
||||
'Depuis Immich': 'From Immich',
|
||||
'Aperçu indisponible': 'Preview unavailable',
|
||||
'Un souvenir à regarder de plus près': 'A memory to take a closer look at',
|
||||
'Retrouver vos souvenirs': 'Find your memories',
|
||||
'Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.': 'Open an image for a closer look, then add it to memories.',
|
||||
'Retour aux photos': 'Back to photos',
|
||||
'Aperçu de la vidéo': 'Video preview',
|
||||
'Recherche dans Immich': 'Search Immich',
|
||||
'Rechercher': 'Search',
|
||||
'Un repas au jardin, des vacances à la mer…': 'A meal in the garden, a seaside holiday…',
|
||||
'Décrire la photo recherchée': 'Describe the photo you are looking for',
|
||||
'Décrire la vidéo recherchée': 'Describe the video you are looking for',
|
||||
'ou': 'or',
|
||||
'depuis Immich': 'from Immich',
|
||||
'1 souvenir choisi dans Immich': '1 memory picked from Immich',
|
||||
'{n} souvenirs choisis dans Immich': '{n} memories picked from Immich',
|
||||
'Souvenirs choisis dans Immich': 'Memories picked from Immich',
|
||||
'En ajouter d’autres': 'Add more',
|
||||
'Ajouter ces {n} souvenirs': 'Add these {n} memories',
|
||||
'Filtrer par visage': 'Filter by face',
|
||||
'Effacer les filtres': 'Clear filters',
|
||||
'Personne reconnue dans Immich': 'Person recognized in Immich',
|
||||
'Tous les visages': 'All faces',
|
||||
'Précisez un nom pour voir les autres visages.': 'Enter a name to see more faces.',
|
||||
'La recherche par contexte utilise la recherche intelligente activée dans Immich.': 'Context search uses smart search enabled in Immich.',
|
||||
'À la recherche de vos souvenirs…': 'Looking for your memories…',
|
||||
'Agrandir {name}': 'Enlarge {name}',
|
||||
'Sélectionner {name}': 'Select {name}',
|
||||
'Essayez une autre description ou un autre visage.': 'Try another description or face.',
|
||||
'Pages des souvenirs Immich': 'Immich memories pages',
|
||||
'Page {page}': 'Page {page}',
|
||||
'1 souvenir sélectionné': '1 memory selected',
|
||||
'{n} souvenirs sélectionnés': '{n} memories selected',
|
||||
'Photo : 5 Mo · Vidéo : 20 Mo': 'Photo: 5 MB · Video: 20 MB',
|
||||
'Vos médias seront copiés dans Parenthèse.': 'Your media will be copied to Parenthèse.',
|
||||
'Choisir ce souvenir': 'Choose this memory',
|
||||
'Fichier trop volumineux (photo : 5 Mo, vidéo : 20 Mo).': 'File too large (photo: 5 MB, video: 20 MB).',
|
||||
'Média introuvable dans Immich.': 'Media not found in Immich.',
|
||||
'Format de média non pris en charge.': 'Unsupported media format.',
|
||||
'Type': 'Type',
|
||||
'Photos': 'Photos',
|
||||
'Vidéos': 'Videos',
|
||||
'Ajouté': 'Added',
|
||||
'Aucun média trouvé.': 'No media found.',
|
||||
'Téléchargement…': 'Downloading…',
|
||||
'Précédent': 'Previous',
|
||||
'Suivant': 'Next',
|
||||
'Terminer la sélection': 'Finish selecting',
|
||||
'Depuis un fichier': 'From a file',
|
||||
'Source du portrait': 'Portrait source',
|
||||
'Visage Immich': 'Immich face',
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
--pz-success-bg: #e6f2e4;
|
||||
--pz-shadow: 0 1px 2px rgba(42, 38, 34, .05), 0 8px 24px rgba(42, 38, 34, .07);
|
||||
--pz-shadow-lg: 0 30px 80px rgba(42, 38, 34, .22), 0 2px 6px rgba(42, 38, 34, .06);
|
||||
--pz-media-badge-bg: rgba(42, 38, 34, .75);
|
||||
--pz-z-modal-nested: 2400;
|
||||
--pz-serif: 'Newsreader', Georgia, 'Times New Roman', serif;
|
||||
--pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
--pz-ease: cubic-bezier(.23, 1, .32, 1);
|
||||
|
||||
Reference in new issue
Block a user