diff --git a/app/admin/images/page.tsx b/app/admin/images/page.tsx
index 07025b9..e958b4f 100644
--- a/app/admin/images/page.tsx
+++ b/app/admin/images/page.tsx
@@ -13,10 +13,11 @@ interface SiteImage {
type UploadState = "idle" | "uploading" | "saving" | "done" | "error";
interface ImageCardState {
- editUrl: string; // valeur brute en cours d'édition
- previewUrl: string; // URL pour l'aperçu
+ editUrl: string; // valeur brute en cours d'édition
+ previewUrl: string; // URL pour l'aperçu
uploadState: UploadState;
uploadError: string | null;
+ optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)"
}
export default function AdminImages() {
@@ -40,6 +41,7 @@ export default function AdminImages() {
previewUrl: img.previewUrl,
uploadState: "idle",
uploadError: null,
+ optimizationSummary: null,
};
}
setCardState(state);
@@ -73,7 +75,10 @@ export default function AdminImages() {
return;
}
- const { storagePath } = uploadData as { storagePath: string };
+ const { storagePath, optimization } = uploadData as {
+ storagePath: string;
+ optimization?: { summary: string; inRange: boolean };
+ };
// Sauvegarde automatique en BDD
updateCard(key, { uploadState: "saving" });
@@ -90,7 +95,12 @@ export default function AdminImages() {
}
// Succès : mettre à jour l'état
- updateCard(key, { editUrl: storagePath, uploadState: "done", uploadError: null });
+ updateCard(key, {
+ editUrl: storagePath,
+ uploadState: "done",
+ uploadError: null,
+ optimizationSummary: optimization?.summary ?? null,
+ });
setImages((prev) =>
prev.map((img) =>
img.key === key
@@ -98,8 +108,11 @@ export default function AdminImages() {
: img
)
);
- setGlobalMessage({ type: "success", text: `"${key}" uploadé et sauvegardé !` });
- setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000);
+ const successMsg = optimization
+ ? `"${key}" sauvegardé — ${optimization.summary}`
+ : `"${key}" uploadé et sauvegardé !`;
+ setGlobalMessage({ type: "success", text: successMsg });
+ setTimeout(() => updateCard(key, { uploadState: "idle", optimizationSummary: null }), 5000);
} catch {
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
}
@@ -179,7 +192,7 @@ export default function AdminImages() {
Images du site
- Uploadez vos fichiers directement dans le bucket privé Supabase, ou collez une URL externe.
+ Uploadez vos fichiers directement dans le bucket privé Supabase. Les images sont automatiquement converties en WebP et optimisées entre 300 Ko et 1 Mo.
@@ -301,18 +314,23 @@ CREATE POLICY "service_role_full_access"
/>
{isUploading ? (
- Upload en cours...
+ Optimisation et upload en cours...
) : isSaving ? (
Sauvegarde...
) : isDone ? (
- Fichier enregistré !
+
+
Fichier enregistré !
+ {state.optimizationSummary && (
+
{state.optimizationSummary}
+ )}
+
) : (
Glissez une image ou{" "}
parcourir
-
JPEG · PNG · WebP · GIF · AVIF — max 5 Mo
+
JPEG · PNG · WebP · AVIF — converti en WebP, max 20 Mo
)}
diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts
index 61fa53e..73702f8 100644
--- a/app/api/admin/upload/route.ts
+++ b/app/api/admin/upload/route.ts
@@ -1,18 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient, createAdminClient } from "@/lib/supabase/server";
import type { Profile } from "@/types/database.types";
+import sharp from "sharp";
const BUCKET = "private-gallery";
-// ── Types MIME autorisés → extension de stockage ───────────────────────────
-// L'extension est dérivée du MIME validé côté serveur, jamais du nom de fichier.
-const MIME_TO_EXT: Record = {
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
- "image/gif": "gif",
- "image/avif": "avif",
-};
+// Taille cible : entre 300 Ko et 1 Mo
+const TARGET_MAX_BYTES = 1_000_000; // 1 Mo
+const TARGET_MIN_BYTES = 300_000; // 300 Ko (indicatif — on ne force pas l'inflation)
+
+// Paliers de qualité WebP : on descend jusqu'à rentrer sous 1 Mo
+const QUALITY_STEPS = [82, 72, 62, 50];
// ── Signatures magic bytes ──────────────────────────────────────────────────
// Permet de détecter le vrai format binaire indépendamment du Content-Type
@@ -33,7 +31,6 @@ const MAGIC_SIGNATURES: Array<{
},
{
mime: "image/gif",
- // GIF87a ou GIF89a
check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38,
},
{
@@ -61,6 +58,39 @@ function detectMimeFromBytes(buffer: Uint8Array): string | null {
return null;
}
+/**
+ * Optimise l'image :
+ * – Conversion en WebP (meilleur ratio qualité/poids sur le web)
+ * – Auto-rotation via l'orientation EXIF (corrige les photos de téléphone)
+ * – Strip de toutes les métadonnées (GPS, modèle appareil, EXIF) — les navigateurs assument sRGB
+ * – Compression adaptative : démarre à q82, descend par paliers si > 1 Mo
+ *
+ * Retourne le buffer WebP optimisé et les stats (pour logging).
+ */
+async function optimizeToWebP(
+ input: Buffer
+): Promise<{ buffer: Buffer; quality: number; originalBytes: number; finalBytes: number }> {
+ const originalBytes = input.length;
+
+ for (const quality of QUALITY_STEPS) {
+ const output = await sharp(input)
+ .rotate() // Auto-rotation EXIF (corrige portrait/paysage)
+ // withMetadata() non appelé → Sharp strip tout par défaut :
+ // GPS, modèle appareil, IPTC… supprimés. Navigateurs assument sRGB.
+ .webp({ quality, effort: 4 }) // effort 4 = bon compromis vitesse/compression
+ .toBuffer();
+
+ // On s'arrête dès qu'on passe sous 1 Mo
+ // ou si on est déjà au dernier palier (on prend quoi qu'il en soit)
+ if (output.length <= TARGET_MAX_BYTES || quality === QUALITY_STEPS.at(-1)) {
+ return { buffer: output, quality, originalBytes, finalBytes: output.length };
+ }
+ }
+
+ // Ne devrait jamais être atteint — TypeScript exige un return exhaustif
+ throw new Error("Optimisation impossible");
+}
+
async function checkAdmin() {
const supabase = await createClient();
const {
@@ -78,7 +108,7 @@ async function checkAdmin() {
return (profile as Pick | null)?.is_admin === true;
}
-// POST - Upload un fichier dans le bucket private-gallery
+// POST — Upload + optimisation automatique vers Supabase Storage
export async function POST(request: NextRequest) {
const isAdmin = await checkAdmin();
if (!isAdmin) {
@@ -99,54 +129,50 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 });
}
- // ── 1. Vérifier le type MIME déclaré ──────────────────────────────────────
- if (!Object.hasOwn(MIME_TO_EXT, file.type)) {
- return NextResponse.json(
- { error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." },
- { status: 400 }
- );
+ // ── 1. Limite de taille brute (avant optimisation) ────────────────────────
+ if (file.size > 20 * 1024 * 1024) {
+ return NextResponse.json({ error: "Fichier trop volumineux (max 20 Mo avant optimisation)" }, { status: 400 });
}
- // ── 2. Limite de taille ───────────────────────────────────────────────────
- if (file.size > 5 * 1024 * 1024) {
- return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 });
- }
-
- // ── 3. Lire le contenu binaire ────────────────────────────────────────────
+ // ── 2. Lire le contenu binaire ────────────────────────────────────────────
const arrayBuffer = await file.arrayBuffer();
- const buffer = new Uint8Array(arrayBuffer);
+ const rawBuffer = new Uint8Array(arrayBuffer);
- // ── 4. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
- // On détecte le vrai format à partir des octets du fichier, pas du Content-Type
- // envoyé par le client.
- const detectedMime = detectMimeFromBytes(buffer);
+ // ── 3. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
+ const detectedMime = detectMimeFromBytes(rawBuffer);
if (!detectedMime) {
return NextResponse.json(
- { error: "Le fichier ne correspond pas à un format image valide." },
- { status: 400 }
- );
- }
- // Le MIME réel doit correspondre au MIME déclaré
- if (detectedMime !== file.type) {
- return NextResponse.json(
- { error: "Le type du fichier ne correspond pas à son contenu." },
+ { error: "Le fichier ne correspond pas à un format image valide (JPEG, PNG, WebP, AVIF, GIF)." },
{ status: 400 }
);
}
+ // ── 4. Optimisation : conversion WebP + compression adaptative ────────────
+ let optimized: Awaited>;
+ try {
+ optimized = await optimizeToWebP(Buffer.from(rawBuffer));
+ } catch {
+ return NextResponse.json(
+ { error: "Erreur lors de l'optimisation de l'image." },
+ { status: 500 }
+ );
+ }
+
+ const { buffer, quality, originalBytes, finalBytes } = optimized;
+
// ── 5. Construire le chemin de stockage ───────────────────────────────────
- // Extension toujours dérivée du MIME validé, jamais du nom de fichier fourni.
- const ext = MIME_TO_EXT[detectedMime];
+ // Toujours .webp quelle que soit l'entrée (JPEG, PNG, AVIF…)
const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_");
- const filePath = `${sanitizedKey}/image.${ext}`;
+ const filePath = `${sanitizedKey}/image.webp`;
// ── 6. Upload vers Supabase Storage ──────────────────────────────────────
const adminClient = createAdminClient();
const { error } = await adminClient.storage
.from(BUCKET)
.upload(filePath, buffer, {
- contentType: detectedMime,
+ contentType: "image/webp",
upsert: true,
+ cacheControl: "public, max-age=31536000", // 1 an (CDN Supabase)
});
if (error) {
@@ -157,5 +183,22 @@ export async function POST(request: NextRequest) {
}
const storagePath = `storage:${filePath}`;
- return NextResponse.json({ storagePath, filePath });
+
+ // Infos retournées pour le feedback admin
+ const originalKb = Math.round(originalBytes / 1024);
+ const finalKb = Math.round(finalBytes / 1024);
+ const inRange = finalBytes >= TARGET_MIN_BYTES && finalBytes <= TARGET_MAX_BYTES;
+
+ return NextResponse.json({
+ storagePath,
+ filePath,
+ optimization: {
+ originalKb,
+ finalKb,
+ quality,
+ inRange,
+ // Message lisible en fr pour l'UI
+ summary: `${originalKb} Ko → ${finalKb} Ko (WebP q${quality})`,
+ },
+ });
}
diff --git a/package-lock.json b/package-lock.json
index 16ec86a..6620af9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,13 +17,14 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"resend": "^6.9.1",
+ "sharp": "^0.34.5",
"stripe": "^20.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@sanity/vision": "^3.99.0",
"@tailwindcss/postcss": "^4",
- "@types/node": "^20",
+ "@types/node": "^20.19.33",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"eslint": "^9",
@@ -3231,7 +3232,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -10521,7 +10521,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"
@@ -18452,7 +18451,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -18496,7 +18494,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/package.json b/package.json
index af1fca2..05828a2 100644
--- a/package.json
+++ b/package.json
@@ -18,13 +18,14 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"resend": "^6.9.1",
+ "sharp": "^0.34.5",
"stripe": "^20.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@sanity/vision": "^3.99.0",
"@tailwindcss/postcss": "^4",
- "@types/node": "^20",
+ "@types/node": "^20.19.33",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"eslint": "^9",