feat: variantes responsives 320/800/1600 via sharp + srcset partout (Reels, cards, galerie, favoris)
All checks were successful
CI / test (pull_request) Successful in 2m21s
All checks were successful
CI / test (pull_request) Successful in 2m21s
This commit is contained in:
parent
e542a853fa
commit
e2d3b6a686
11 changed files with 312 additions and 6 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
56
src/components/ResponsiveImage.tsx
Normal file
56
src/components/ResponsiveImage.tsx
Normal 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
41
src/lib/image-variants.ts
Normal 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
126
src/lib/variants-server.ts
Normal 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 };
|
||||
}
|
||||
38
tests/lib/image-variants.test.ts
Normal file
38
tests/lib/image-variants.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue