From e2d3b6a686094795d349e93026decec7d8806bea Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 01:05:25 +0000 Subject: [PATCH] feat: variantes responsives 320/800/1600 via sharp + srcset partout (Reels, cards, galerie, favoris) --- package-lock.json | 5 +- package.json | 1 + src/app/api/uploads/finalize/route.ts | 23 ++++ src/app/carbets/_components/carbet-card.tsx | 6 +- .../carbets/_components/carbet-gallery.tsx | 12 ++ src/app/decouvrir/_components/ReelSlide.tsx | 5 + src/app/mes-favoris/page.tsx | 5 + src/components/ResponsiveImage.tsx | 56 ++++++++ src/lib/image-variants.ts | 41 ++++++ src/lib/variants-server.ts | 126 ++++++++++++++++++ tests/lib/image-variants.test.ts | 38 ++++++ 11 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/components/ResponsiveImage.tsx create mode 100644 src/lib/image-variants.ts create mode 100644 src/lib/variants-server.ts create mode 100644 tests/lib/image-variants.test.ts diff --git a/package-lock.json b/package-lock.json index 9dcbdb0..7d8475d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -1646,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -5398,7 +5398,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9574,7 +9573,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -9618,7 +9616,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 5bb9e15..e0a10f1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-dom": "19.2.4", "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts index 91fd2cd..c9f7cd8 100644 --- a/src/app/api/uploads/finalize/route.ts +++ b/src/app/api/uploads/finalize/route.ts @@ -6,6 +6,7 @@ import { MediaType, UserRole } from "@/generated/prisma/enums"; import { prisma } from "@/lib/prisma"; import { classifyMime } from "@/lib/uploads"; import { recordAudit } from "@/lib/admin/audit"; +import { generateImageVariants } from "@/lib/variants-server"; export const runtime = "nodejs"; @@ -62,5 +63,27 @@ export async function POST(req: Request) { actorEmail: session.user.email ?? null, details: { carbetId: carbet.id, kind }, }); + + // Génération des variantes responsives (best-effort, n'échoue pas la requête). + // L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure. + try { + const variants = await generateImageVariants({ + originalS3Key: parsed.data.s3Key, + mime: parsed.data.mime, + }); + if (!variants.skipped) { + const okCount = variants.results.filter((r) => r.ok).length; + await recordAudit({ + scope: "uploads", + event: "media.variants", + target: media.id, + actorEmail: session.user.email ?? null, + details: { generated: okCount, total: variants.results.length }, + }); + } + } catch (e) { + console.error("[uploads] variants generation error:", e); + } + return NextResponse.json({ media }); } diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index 9a6a53b..c11003a 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import type { CarbetSearchResult } from "@/lib/carbet-search"; import { formatPirogueDuration, truncate } from "@/lib/format"; import { formatAverageRating } from "@/lib/reviews"; +import { buildSrcSet } from "@/lib/image-variants"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; import { StayConstraints } from "@/components/StayConstraints"; @@ -14,13 +15,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
{carbet.coverUrl ? ( - // Use a plain here — uploaded media URLs come from MinIO/S3 and - // don't go through next/image's optimizer in this environment. // eslint-disable-next-line @next/next/no-img-element {`Photo ) : ( diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx index a5c7ca1..4122a35 100644 --- a/src/app/carbets/_components/carbet-gallery.tsx +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import type { PublicCarbetMedia } from "@/lib/carbet-public"; import { MediaType } from "@/generated/prisma/enums"; +import { buildSrcSet } from "@/lib/image-variants"; type Props = { title: string; @@ -73,7 +74,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Photo )} @@ -101,8 +106,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Média )} @@ -179,7 +187,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Photo )} diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx index 26e3809..a8476f4 100644 --- a/src/app/decouvrir/_components/ReelSlide.tsx +++ b/src/app/decouvrir/_components/ReelSlide.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import type { ReelCarbet } from "@/lib/reels"; +import { buildSrcSet } from "@/lib/image-variants"; type Props = { carbet: ReelCarbet; @@ -115,9 +116,13 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl // eslint-disable-next-line @next/next/no-img-element {`${carbet.title} )} diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx index 6ec4097..5887400 100644 --- a/src/app/mes-favoris/page.tsx +++ b/src/app/mes-favoris/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { auth } from "@/auth"; import { listFavoriteCarbets } from "@/lib/reels"; +import { buildSrcSet } from "@/lib/image-variants"; export const dynamic = "force-dynamic"; @@ -41,7 +42,11 @@ export default async function MyFavoritesPage() { // eslint-disable-next-line @next/next/no-img-element {c.title} ) : ( diff --git a/src/components/ResponsiveImage.tsx b/src/components/ResponsiveImage.tsx new file mode 100644 index 0000000..61bfaa6 --- /dev/null +++ b/src/components/ResponsiveImage.tsx @@ -0,0 +1,56 @@ +/** + * avec srcset/sizes pré-rempli sur les variantes Karbé. + * Drop-in remplacement pour les balises `` côté front. + */ + +import { buildSrcSet } from "@/lib/image-variants"; + +type Props = { + src: string; + alt: string; + /** Indication CSS pour le browser. Ex: "(min-width: 768px) 800px, 100vw" */ + sizes?: string; + className?: string; + loading?: "lazy" | "eager"; + fetchPriority?: "high" | "low" | "auto"; + width?: number; + height?: number; + decoding?: "async" | "sync" | "auto"; + draggable?: boolean; + style?: React.CSSProperties; + onClick?: () => void; +}; + +export function ResponsiveImage({ + src, + alt, + sizes = "(min-width: 768px) 800px, 100vw", + className, + loading = "lazy", + fetchPriority = "auto", + width, + height, + decoding = "async", + draggable, + style, + onClick, +}: Props) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} diff --git a/src/lib/image-variants.ts b/src/lib/image-variants.ts new file mode 100644 index 0000000..5d0e22a --- /dev/null +++ b/src/lib/image-variants.ts @@ -0,0 +1,41 @@ +/** + * Variantes responsive : génération + URL helpers. + * + * Convention de nommage : .jpg -> -320.jpg, -800.jpg, -1600.jpg. + * Le format est forcé à JPEG pour les variantes (compression efficace, + * supporté partout). L'original reste tel quel (PNG/WebP/AVIF préservés). + * + * Helper côté client : variantUrl(originalUrl, width) → URL de la variante. + * Le browser fait le fallback automatiquement via srcset si la variante 404. + */ + +export const VARIANT_WIDTHS = [320, 800, 1600] as const; +export type VariantWidth = (typeof VARIANT_WIDTHS)[number]; + +/** Calcule l'URL d'une variante depuis l'URL originale. */ +export function variantUrl(originalUrl: string, width: VariantWidth): string { + const lastDot = originalUrl.lastIndexOf("."); + if (lastDot === -1) return originalUrl; + const base = originalUrl.slice(0, lastDot); + return `${base}-${width}.jpg`; +} + +/** Calcule la s3Key d'une variante depuis la s3Key originale. */ +export function variantS3Key(originalKey: string, width: VariantWidth): string { + const lastDot = originalKey.lastIndexOf("."); + if (lastDot === -1) return originalKey; + const base = originalKey.slice(0, lastDot); + return `${base}-${width}.jpg`; +} + +/** + * srcSet attribut pour un ``. Le browser pick la meilleure variante + * selon viewport+DPR. Si une variante 404, srcset fallback en cascade ; + * on ajoute toujours l'original comme dernière entrée pour garantir + * qu'au moins UNE source fonctionne. + */ +export function buildSrcSet(originalUrl: string): string { + return VARIANT_WIDTHS.map((w) => `${variantUrl(originalUrl, w)} ${w}w`) + .concat([`${originalUrl} 2000w`]) + .join(", "); +} diff --git a/src/lib/variants-server.ts b/src/lib/variants-server.ts new file mode 100644 index 0000000..06f3177 --- /dev/null +++ b/src/lib/variants-server.ts @@ -0,0 +1,126 @@ +/** + * Génération de variantes responsive côté serveur (Node). + * + * - Télécharge l'original depuis MinIO via l'endpoint interne. + * - sharp → 3 variantes (320 / 800 / 1600 px de large max, JPEG quality 80). + * - Upload chaque variante avec naming convention -.jpg. + * - Skippe vidéos (sharp ne les traite pas). + * + * Best-effort : si une variante échoue, on log et on continue. L'original + * fonctionne toujours côté front grâce au srcset fallback. + */ + +import "server-only"; + +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import type { Readable } from "node:stream"; + +import { VARIANT_WIDTHS, variantS3Key, type VariantWidth } from "./image-variants"; + +const ENDPOINT = process.env.S3_ENDPOINT ?? ""; +const BUCKET = process.env.S3_BUCKET ?? ""; +const REGION = process.env.S3_REGION ?? "us-east-1"; +const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? ""; +const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? ""; + +const s3 = new S3Client({ + endpoint: ENDPOINT, + region: REGION, + forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true", + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }, +}); + +async function streamToBuffer(stream: Readable | ReadableStream): Promise { + if ("getReader" in stream) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks); + } + const chunks: Buffer[] = []; + for await (const c of stream as Readable) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +export type VariantResult = { + width: VariantWidth; + s3Key: string; + ok: boolean; + reason?: string; +}; + +/** + * Génère les 3 variantes responsives pour une image originale. + * Skip silencieusement si mime === video/*. + */ +export async function generateImageVariants(opts: { + originalS3Key: string; + mime: string; +}): Promise<{ skipped: boolean; results: VariantResult[] }> { + if (opts.mime.startsWith("video/")) { + return { skipped: true, results: [] }; + } + + let sharp: (input: Buffer) => import("sharp").Sharp; + try { + const mod = await import("sharp"); + sharp = (mod as unknown as { default: (input: Buffer) => import("sharp").Sharp }).default; + } catch { + return { skipped: true, results: [] }; + } + + // 1. Download original + let originalBuffer: Buffer; + try { + const get = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: opts.originalS3Key })); + if (!get.Body) throw new Error("Empty body"); + originalBuffer = await streamToBuffer(get.Body as Readable); + } catch (e) { + return { + skipped: false, + results: VARIANT_WIDTHS.map((w) => ({ + width: w, + s3Key: variantS3Key(opts.originalS3Key, w), + ok: false, + reason: e instanceof Error ? e.message : "download failed", + })), + }; + } + + // 2. Variantes en parallèle + const results = await Promise.all( + VARIANT_WIDTHS.map(async (w): Promise => { + const targetKey = variantS3Key(opts.originalS3Key, w); + try { + const buf = await sharp(originalBuffer) + .rotate() // respecte l'EXIF orientation + .resize({ width: w, withoutEnlargement: true }) + .jpeg({ quality: 80, progressive: true, mozjpeg: true }) + .toBuffer(); + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: targetKey, + Body: buf, + ContentType: "image/jpeg", + CacheControl: "public, max-age=31536000, immutable", + }), + ); + return { width: w, s3Key: targetKey, ok: true }; + } catch (e) { + return { + width: w, + s3Key: targetKey, + ok: false, + reason: e instanceof Error ? e.message : "resize/upload failed", + }; + } + }), + ); + + return { skipped: false, results }; +} diff --git a/tests/lib/image-variants.test.ts b/tests/lib/image-variants.test.ts new file mode 100644 index 0000000..18fec19 --- /dev/null +++ b/tests/lib/image-variants.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; + +import { VARIANT_WIDTHS, buildSrcSet, variantS3Key, variantUrl } from "@/lib/image-variants"; + +describe("VARIANT_WIDTHS", () => { + it("contient 320, 800, 1600", () => { + expect(VARIANT_WIDTHS).toEqual([320, 800, 1600]); + }); +}); + +describe("variantUrl", () => { + it("transforme .jpg en -320.jpg", () => { + expect(variantUrl("https://x/y/abc.jpg", 320)).toBe("https://x/y/abc-320.jpg"); + }); + it("force JPEG sortie même pour PNG/WebP en input", () => { + expect(variantUrl("https://x/y/abc.png", 800)).toBe("https://x/y/abc-800.jpg"); + expect(variantUrl("https://x/y/abc.webp", 1600)).toBe("https://x/y/abc-1600.jpg"); + }); + it("renvoie l'original si pas d'extension", () => { + expect(variantUrl("https://x/y/abc", 320)).toBe("https://x/y/abc"); + }); +}); + +describe("variantS3Key", () => { + it("transforme correctement la s3Key", () => { + expect(variantS3Key("carbets/foo/123-abc.jpg", 800)).toBe("carbets/foo/123-abc-800.jpg"); + }); +}); + +describe("buildSrcSet", () => { + it("contient les 3 variantes + fallback original", () => { + const set = buildSrcSet("https://x/abc.jpg"); + expect(set).toContain("abc-320.jpg 320w"); + expect(set).toContain("abc-800.jpg 800w"); + expect(set).toContain("abc-1600.jpg 1600w"); + expect(set).toContain("abc.jpg 2000w"); + }); +});