From 291dde8073739e0a2c924eaafe743fe4ff53e9c4 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 27 Oct 2026 16:17:58 +0100 Subject: [PATCH] =?UTF-8?q?Aper=C3=A7us=20flous=20(BlurHash)=20pendant=20l?= =?UTF-8?q?e=20chargement=20des=20photos=20et=20des=20portraits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu. - Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001). - API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide. - Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche, portraits et souvenirs dessinés sur l'arbre. Co-Authored-By: Claude Opus 5.5 --- backend/package-lock.json | 7 ++ backend/package.json | 1 + .../20261027_001_apercus_flous/migration.sql | 7 ++ backend/prisma/schema.prisma | 4 + backend/src/app.ts | 5 +- backend/src/lib/blurhash.ts | 92 ++++++++++++++++++ backend/src/lib/entity-deletion.ts | 2 +- backend/src/routes/immich.ts | 4 + backend/src/routes/media.ts | 4 + backend/src/utils/graph-payload.ts | 4 + backend/test/apercus-flous.db.test.ts | 95 +++++++++++++++++++ backend/test/blurhash.test.ts | 26 +++++ frontend/package-lock.json | 7 ++ frontend/package.json | 1 + frontend/src/components/LifeTimeline.jsx | 3 +- frontend/src/components/MediaManagerPanel.jsx | 3 +- frontend/src/components/PersonCard.jsx | 12 ++- frontend/src/components/galaxy/renderers.js | 10 +- frontend/src/data/mockData.js | 3 + frontend/src/utils/blurhash.js | 45 +++++++++ 20 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 backend/prisma/migrations/20261027_001_apercus_flous/migration.sql create mode 100644 backend/src/lib/blurhash.ts create mode 100644 backend/test/apercus-flous.db.test.ts create mode 100644 backend/test/blurhash.test.ts create mode 100644 frontend/src/utils/blurhash.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 6e60064..b1d1db4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "@node-rs/bcrypt": "^1.10.7", "@prisma/adapter-pg": "^7.10.1", "@prisma/client": "^7.10.1", + "blurhash": "^2.0.5", "dotenv": "^18.0.8", "fastify": "^5.12.7", "openid-client": "^6.8.8", @@ -3033,6 +3034,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.5", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", diff --git a/backend/package.json b/backend/package.json index e933fb8..33d2bd3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "@node-rs/bcrypt": "^1.10.7", "@prisma/adapter-pg": "^7.10.1", "@prisma/client": "^7.10.1", + "blurhash": "^2.0.5", "dotenv": "^18.0.8", "fastify": "^5.12.7", "openid-client": "^6.8.8", diff --git a/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql b/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql new file mode 100644 index 0000000..ade33b7 --- /dev/null +++ b/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql @@ -0,0 +1,7 @@ +-- Aperçus flous (BlurHash) des portraits et des photos, montrés pendant le chargement. +-- Migration additive : deux colonnes facultatives, remplies ensuite par l'API (lib/blurhash.ts). +-- AlterTable +ALTER TABLE "persons" ADD COLUMN "avatarBlurhash" TEXT; + +-- AlterTable +ALTER TABLE "media_items" ADD COLUMN "blurhash" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bc300f7..df2ab67 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -310,6 +310,8 @@ model Person { // { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière // recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1). avatarCrop Json? + // Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible + avatarBlurhash String? sex String? notes String? createdBy String? @@ -370,6 +372,8 @@ model MediaItem { mimeType String filePath String thumbPath String? + // Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible + blurhash String? caption String? source String? sizeBytes Int diff --git a/backend/src/app.ts b/backend/src/app.ts index bb92d21..73e8078 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,13 +10,14 @@ import { env } from './config/env.js' import { prisma } from './lib/prisma.js' import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js' import { registerIdempotency } from './lib/idempotency.js' +import { startBlurhashFiller } from './lib/blurhash.js' import { authRoutes } from './routes/auth.js' import { contributionRoutes } from './routes/contributions.js' import { graphRoutes } from './routes/graph.js' import { immichRoutes } from './routes/immich.js' import { managerRoutes } from './routes/managers.js' import { searchSourceRoutes } from './routes/search-sources.js' -import { mediaRoutes } from './routes/media.js' +import { getStorageRoot, mediaRoutes } from './routes/media.js' import { oidcRoutes } from './routes/oidc.js' import { publicRoutes } from './routes/public.js' import { annotationRoutes } from './routes/annotations.js' @@ -65,6 +66,8 @@ export function createApp() { app.addHook('onReady', async () => { await loadRevokedTokens(app.prisma) }) + // Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests + if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot())) app.decorateRequest('actor', null) app.register(cors, { diff --git a/backend/src/lib/blurhash.ts b/backend/src/lib/blurhash.ts new file mode 100644 index 0000000..ca3201a --- /dev/null +++ b/backend/src/lib/blurhash.ts @@ -0,0 +1,92 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { encode } from 'blurhash' +import sharp from 'sharp' +import type { FastifyInstance } from 'fastify' + +// Aperçus flous (BlurHash) : une courte chaîne par photo, que le navigateur décode en image floue aux bonnes +// couleurs pendant que la vraie se charge. Les photos partent en taille réelle, sans miniature : sur un réseau +// lent, une vignette restait un carré vide. +// +// Calculé à l'envoi d'une photo ou d'un portrait (routes/media.ts). Les autres chemins (Immich, import GEDCOM, +// propositions acceptées) et les photos d'avant cette version sont rattrapés en tâche de fond. Une image +// illisible reçoit une chaîne vide, pour ne pas être relue à chaque passage. + +// Assez petit pour un calcul rapide ; l'aperçu garde les proportions de la photo +const SAMPLE_MAX = 32 +const COMPONENTS_X = 4 +const COMPONENTS_Y = 3 + +export async function computeBlurhash(buf: Buffer): Promise { + try { + const { data, info } = await sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' }) + .rotate() + .resize(SAMPLE_MAX, SAMPLE_MAX, { fit: 'inside' }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + return encode(new Uint8ClampedArray(data), info.width, info.height, COMPONENTS_X, COMPONENTS_Y) + } catch { + return null + } +} + +async function hashStoredFile(storageRoot: string, relativePath: string): Promise { + const absolutePath = path.resolve(storageRoot, relativePath) + if (!absolutePath.startsWith(storageRoot + path.sep)) return '' + const buf = await readFile(absolutePath).catch(() => null) + return (buf && await computeBlurhash(buf)) || '' +} + +const BATCH = 25 +const PAUSE_MS = 60_000 + +// Un lot de photos puis de portraits sans aperçu ; renvoie le nombre traité +export async function fillMissingBlurhashes(app: FastifyInstance, storageRoot: string): Promise { + const medias = await app.prisma.mediaItem.findMany({ + where: { type: 'photo', blurhash: null, deletedAt: null, NOT: { mimeType: 'video/youtube' } }, + select: { id: true, filePath: true }, + take: BATCH, + }) + for (const media of medias) { + const blurhash = await hashStoredFile(storageRoot, media.filePath) + await app.prisma.mediaItem.updateMany({ where: { id: media.id, blurhash: null }, data: { blurhash } }) + } + + const persons = await app.prisma.person.findMany({ + where: { avatarPath: { not: null }, avatarBlurhash: null, deletedAt: null }, + select: { id: true, avatarPath: true }, + take: BATCH, + }) + for (const person of persons) { + const avatarBlurhash = await hashStoredFile(storageRoot, person.avatarPath as string) + // Le portrait a pu changer pendant le calcul : on n'écrit que s'il est toujours le même + await app.prisma.person.updateMany({ where: { id: person.id, avatarPath: person.avatarPath, avatarBlurhash: null }, data: { avatarBlurhash } }) + } + + return medias.length + persons.length +} + +// Tâche de fond : enchaîne les lots tant qu'il en reste, puis repasse toutes les minutes +export function startBlurhashFiller(app: FastifyInstance, storageRoot: string) { + let timer: NodeJS.Timeout | null = null + let stopped = false + const run = async () => { + let done = 0 + try { + done = await fillMissingBlurhashes(app, storageRoot) + } catch (error) { + app.log.warn({ err: error }, 'aperçus flous : lot en échec') + } + if (!stopped) { + timer = setTimeout(run, done >= BATCH ? 0 : PAUSE_MS) + timer.unref() + } + } + timer = setTimeout(run, 5_000) + timer.unref() + app.addHook('onClose', async () => { + stopped = true + if (timer) clearTimeout(timer) + }) +} diff --git a/backend/src/lib/entity-deletion.ts b/backend/src/lib/entity-deletion.ts index fa087c4..56ae42b 100644 --- a/backend/src/lib/entity-deletion.ts +++ b/backend/src/lib/entity-deletion.ts @@ -32,7 +32,7 @@ export async function softDeletePersonCascade( await db.person.updateMany({ where: { id: personId, treeId, deletedAt: null }, - data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null }, + data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, avatarBlurhash: null, immichPersonId: null }, }) const unions = await db.union.findMany({ diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 284af00..864431b 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -16,6 +16,7 @@ import { ImmichUnavailableError, type ImmichPersonSummary, } from '../lib/immich.js' +import { computeBlurhash } from '../lib/blurhash.js' import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js' @@ -539,6 +540,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { avatarMimeType: prepared.mimeType, // Nouveau portrait, nouveau cadrage à faire avatarCrop: Prisma.DbNull, + avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '', immichPersonId, updatedBy: request.actor.userId, }, @@ -619,11 +621,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { avatarPath?: null avatarMimeType?: null avatarCrop?: typeof Prisma.DbNull + avatarBlurhash?: null } = { immichPersonId: null, updatedBy: request.actor.userId } if (avatarFromImmich) { updateData.avatarPath = null updateData.avatarMimeType = null updateData.avatarCrop = Prisma.DbNull + updateData.avatarBlurhash = null } // Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour. diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index ead6773..40cc631 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -22,6 +22,7 @@ import { type BinaryMediaType, } from '../lib/media-files.js' import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js' +import { computeBlurhash } from '../lib/blurhash.js' import { syncPersonMediaOrder } from '../lib/media-order.js' import { resolveTreeMediaDirectory } from '../lib/tree-purge.js' import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' @@ -501,6 +502,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { status: isPending ? 'pending' : 'approved', contributionSessionId: contributionSession?.id ?? null, lifeEventId, + blurhash: payload.data.type === 'photo' ? await computeBlurhash(prepared.buffer) ?? '' : null, }, }) @@ -620,6 +622,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { avatarMimeType: prepared.mimeType, // Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci avatarCrop: Prisma.DbNull, + avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '', updatedBy: request.actor.userId, // Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association // survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne @@ -676,6 +679,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, + avatarBlurhash: null, // Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait. immichPersonId: null, updatedBy: request.actor.userId, diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 4995bf6..05a63cf 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -26,6 +26,7 @@ export type GraphPayload = { notes: string | null avatarFromImmich: boolean avatarCrop: AvatarCrop | null + avatarBlurhash: string | null }> unions: Array<{ id: string @@ -54,6 +55,7 @@ export type GraphPayload = { source: string | null displayOrder: number isFeatured: boolean + blurhash: string | null // Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1) people: Array<{ personId: string; x: number | null; y: number | null }> }> @@ -117,6 +119,7 @@ export function buildGraphPayload( notes: person.notes, avatarFromImmich: isImmichAvatarPath(person.avatarPath), avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null, + avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null, })), unions: unions.map((union) => ({ id: union.id, @@ -145,6 +148,7 @@ export function buildGraphPayload( source: media.source, displayOrder: media.displayOrder, isFeatured: media.isFeatured, + blurhash: media.blurhash || null, people: peopleByMedia.get(media.id) ?? [], })), annotations: annotations.map((ann) => ({ diff --git a/backend/test/apercus-flous.db.test.ts b/backend/test/apercus-flous.db.test.ts new file mode 100644 index 0000000..5974a7f --- /dev/null +++ b/backend/test/apercus-flous.db.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// Aperçus flous (lib/blurhash.ts), sur une vraie base : calculés à l'envoi d'une photo et d'un portrait, +// publiés dans le graphe, et rattrapés en tâche de fond pour ce qui n'en a pas (photos d'avant la migration, +// imports). Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8) : +// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/apercus-flous.db.test.ts +const TEST_DB = process.env.TEST_DATABASE_URL + +let app: FastifyInstance +let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string } +let ip = 0 +const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}` +const run = Date.now().toString(36) +const storage = mkdtempSync(path.join(tmpdir(), 'pth-apercus-')) +const PHOTO_BYTES = readFileSync(fileURLToPath(new URL('../../frontend/public/demo/souvenir-mariage-1918.webp', import.meta.url))) +const PHOTO = PHOTO_BYTES.toString('base64') + +async function call(method: string, url: string, token?: string, payload?: unknown) { + const res = await app.inject({ + method: method as 'GET', + url, + payload: payload as Record, + headers: token ? { authorization: `Bearer ${token}` } : {}, + remoteAddress: nextIp(), + }) + let body = null + try { body = res.body ? res.json() : null } catch { body = null } + return { status: res.statusCode, body } +} + +describe.skipIf(!TEST_DB)('aperçus flous, vraie base', () => { + let token: string + let treeId: string + let personId: string + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.DATABASE_URL = TEST_DB + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.CORS_ORIGIN = 'http://localhost:5173' + process.env.MEDIA_STORAGE_PATH = storage + ;({ env } = await import('../src/config/env.js')) + const { createApp } = await import('../src/app.js') + app = createApp() + env.ALLOW_REGISTRATION = true + env.MEDIA_STORAGE_PATH = storage + const owner = await call('POST', '/auth/register', undefined, { email: `apercus-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Patch' }) + expect(owner.status).toBe(201) + token = owner.body.token + const tree = await call('POST', '/trees', token, { name: 'Aperçus', slug: `apercus-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' }) + treeId = tree.body.tree?.id ?? tree.body.id + const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' }) + personId = person.body.person?.id ?? person.body.id + }) + + afterAll(async () => { + await app?.close() + }) + + const graph = async () => (await call('GET', `/trees/${treeId}/graph`, token)).body + + it('une photo et un portrait envoyés ont leur aperçu tout de suite, publié dans le graphe', async () => { + const media = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { type: 'photo', fileName: 'mariage.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO }) + expect(media.status).toBe(201) + const avatar = await call('POST', `/trees/${treeId}/persons/${personId}/avatar`, token, { fileName: 'portrait.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO }) + expect(avatar.status).toBeLessThan(300) + const g = await graph() + expect(g.graph?.medias?.[0]?.blurhash ?? g.medias?.[0]?.blurhash).toMatch(/^[0-9A-Za-z#$%*+,-.:;=?@[\]^_{|}~]{28}$/) + const person = (g.graph?.persons ?? g.persons).find((p: { id: string }) => p.id === personId) + expect(person.avatarBlurhash).toHaveLength(28) + }) + + it('la tâche de fond rattrape une photo et un portrait sans aperçu', async () => { + await app.prisma.mediaItem.updateMany({ where: { treeId }, data: { blurhash: null } }) + await app.prisma.person.updateMany({ where: { id: personId }, data: { avatarBlurhash: null } }) + const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js') + const done = await fillMissingBlurhashes(app, path.resolve(storage)) + expect(done).toBeGreaterThanOrEqual(2) + expect((await app.prisma.mediaItem.findFirst({ where: { treeId } }))?.blurhash).toHaveLength(28) + expect((await app.prisma.person.findUnique({ where: { id: personId } }))?.avatarBlurhash).toHaveLength(28) + }) + + it('un fichier illisible reçoit un aperçu vide, pour ne pas être relu à chaque passage', async () => { + const media = await app.prisma.mediaItem.findFirst({ where: { treeId } }) + await app.prisma.mediaItem.update({ where: { id: media!.id }, data: { blurhash: null, filePath: `${treeId}/absent.webp` } }) + const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js') + await fillMissingBlurhashes(app, path.resolve(storage)) + expect((await app.prisma.mediaItem.findUnique({ where: { id: media!.id } }))?.blurhash).toBe('') + }) +}) diff --git a/backend/test/blurhash.test.ts b/backend/test/blurhash.test.ts new file mode 100644 index 0000000..aaddecc --- /dev/null +++ b/backend/test/blurhash.test.ts @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { decode, isBlurhashValid } from 'blurhash' +import { describe, expect, it } from 'vitest' +import { computeBlurhash } from '../src/lib/blurhash.js' + +// Aperçu flou calculé à l'envoi d'une photo : une chaîne valide, aux couleurs de la photo +const demo = (name: string) => fileURLToPath(new URL(`../../frontend/public/demo/${name}`, import.meta.url)) + +describe('computeBlurhash', () => { + it('donne un aperçu valide pour une vraie photo', async () => { + const hash = await computeBlurhash(await readFile(demo('souvenir-mariage-1918.webp'))) + expect(hash).toBeTruthy() + expect(isBlurhashValid(hash as string).result).toBe(true) + // 4 × 3 composantes : 4 + 2 × 4 × 3 - 2 = 28 caractères + expect(hash).toHaveLength(28) + // Photo en noir et blanc : l'aperçu reste gris (rouge, vert et bleu proches) + const px = decode(hash as string, 8, 8) + const [r, g, b] = [px[0], px[1], px[2]] + expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(24) + }) + + it('renvoie null pour un fichier qui n’est pas une image', async () => { + expect(await computeBlurhash(Buffer.from('pas une image'))).toBeNull() + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d7d589..e497b92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@lucide/icons": "^1.59.0", + "blurhash": "^2.0.5", "lucide-react": "^1.59.0", "posthog-js": "^1.438.12", "react": "^19.3.1", @@ -1605,6 +1606,12 @@ "node": ">=6.0.0" } }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c214a6..f29310c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@lucide/icons": "^1.59.0", + "blurhash": "^2.0.5", "lucide-react": "^1.59.0", "posthog-js": "^1.438.12", "react": "^19.3.1", diff --git a/frontend/src/components/LifeTimeline.jsx b/frontend/src/components/LifeTimeline.jsx index 8090f62..63534c3 100644 --- a/frontend/src/components/LifeTimeline.jsx +++ b/frontend/src/components/LifeTimeline.jsx @@ -3,6 +3,7 @@ import { Plus, Play, FileText, Mic, Paperclip } from 'lucide-react' import { t } from '../i18n/index.js' import { buildLifeTimeline } from '../utils/lifeTimeline.js' import './LifeTimeline.css' +import { blurPlaceholder } from '../utils/blurhash.js' // Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements // plus petits entre eux, la famille proche en retrait. Les sources restent repliées. @@ -37,7 +38,7 @@ function Attachments({ medias, onOpen }) {
{photos.map((media) => ( ))}
diff --git a/frontend/src/components/MediaManagerPanel.jsx b/frontend/src/components/MediaManagerPanel.jsx index 952b156..7110d60 100644 --- a/frontend/src/components/MediaManagerPanel.jsx +++ b/frontend/src/components/MediaManagerPanel.jsx @@ -8,6 +8,7 @@ import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' import ImmichAssetPicker from './ImmichAssetPicker.jsx' import { fetchImmichConfig } from '../api/immichApi.js' +import { blurPlaceholder } from '../utils/blurhash.js' const MEDIA_TYPES = [ { value: 'photo', label: t('Photo'), Icon: Image }, @@ -358,7 +359,7 @@ const MediaManagerPanel = ({