Merge pull request 'feat: variantes responsives image' (#67) from feat/responsive-variants into main
All checks were successful
CI / test (push) Successful in 2m9s

This commit is contained in:
tarzzan 2026-06-02 01:05:27 +00:00
commit 3a7c325373
11 changed files with 312 additions and 6 deletions

5
package-lock.json generated
View file

@ -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"
},

View file

@ -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": {

View file

@ -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 });
}

View file

@ -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 }) {
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
{carbet.coverUrl ? (
// Use a plain <img> 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
<img
src={carbet.coverUrl}
srcSet={buildSrcSet(carbet.coverUrl)}
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
alt={`Photo de ${carbet.title}`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (

View file

@ -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
<img
src={cover.url}
srcSet={buildSrcSet(cover.url)}
sizes="(min-width: 768px) 800px, 100vw"
alt={`Photo principale de ${title}`}
fetchPriority="high"
decoding="async"
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
/>
)}
@ -101,8 +106,11 @@ export function CarbetGallery({ title, media }: Props) {
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
srcSet={buildSrcSet(item.url)}
sizes="(min-width: 640px) 200px, 50vw"
alt={`Média de ${title}`}
loading="lazy"
decoding="async"
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
/>
)}
@ -179,7 +187,11 @@ export function CarbetGallery({ title, media }: Props) {
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.url}
srcSet={buildSrcSet(current.url)}
sizes="(min-width: 1200px) 1600px, 92vw"
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
fetchPriority="high"
decoding="async"
className="max-h-[88vh] max-w-[92vw] object-contain"
/>
)}

View file

@ -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
<img
src={shouldPreload ? current.url : undefined}
srcSet={shouldPreload ? buildSrcSet(current.url) : undefined}
sizes="(min-width: 768px) 800px, 100vw"
data-src={current.url}
alt={`${carbet.title} — média ${mediaIndex + 1}`}
loading={shouldPreload ? "eager" : "lazy"}
fetchPriority={shouldPreload ? "high" : "auto"}
decoding="async"
className="h-full w-full object-cover"
/>
)}

View file

@ -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
<img
src={c.media[0].url}
srcSet={buildSrcSet(c.media[0].url)}
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
alt={c.title}
loading="lazy"
decoding="async"
className="aspect-video w-full object-cover"
/>
) : (

View file

@ -0,0 +1,56 @@
/**
* <img> avec srcset/sizes pré-rempli sur les variantes Karbé.
* Drop-in remplacement pour les balises `<img src=… />` 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
<img
src={src}
srcSet={buildSrcSet(src)}
sizes={sizes}
alt={alt}
loading={loading}
fetchPriority={fetchPriority}
decoding={decoding}
width={width}
height={height}
draggable={draggable}
style={style}
className={className}
onClick={onClick}
/>
);
}

41
src/lib/image-variants.ts Normal file
View file

@ -0,0 +1,41 @@
/**
* Variantes responsive : génération + URL helpers.
*
* Convention de nommage : <s3Key>.jpg -> <s3Key>-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 `<img>`. 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(", ");
}

126
src/lib/variants-server.ts Normal file
View file

@ -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 <base>-<w>.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<Uint8Array>): Promise<Buffer> {
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<VariantResult> => {
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 };
}

View file

@ -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");
});
});