feat(upload): optimisation automatique WebP avec Sharp
- Installe sharp pour le traitement d'image côté serveur - Conversion automatique de tout upload en WebP (meilleur ratio qualité/web) - Auto-rotation basée sur l'orientation EXIF (corrige les photos de téléphone) - Strip de toutes les métadonnées personnelles (GPS, appareil, EXIF) - Compression adaptative par paliers (q82 → q72 → q62 → q50) pour viser ≤ 1 Mo - Augmentation de la limite brute à 20 Mo (avant optimisation) - Métadonnées Supabase Storage : Content-Type + Cache-Control 1 an - UI : stats d'optimisation affichées après upload (ex: "2400 Ko → 680 Ko (WebP q82)") - Mise à jour du texte d'aide dans l'admin images https://claude.ai/code/session_01PzA98VhLMmsHpzs7gnLHGs
This commit is contained in:
@@ -13,10 +13,11 @@ interface SiteImage {
|
|||||||
type UploadState = "idle" | "uploading" | "saving" | "done" | "error";
|
type UploadState = "idle" | "uploading" | "saving" | "done" | "error";
|
||||||
|
|
||||||
interface ImageCardState {
|
interface ImageCardState {
|
||||||
editUrl: string; // valeur brute en cours d'édition
|
editUrl: string; // valeur brute en cours d'édition
|
||||||
previewUrl: string; // URL pour l'aperçu
|
previewUrl: string; // URL pour l'aperçu
|
||||||
uploadState: UploadState;
|
uploadState: UploadState;
|
||||||
uploadError: string | null;
|
uploadError: string | null;
|
||||||
|
optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminImages() {
|
export default function AdminImages() {
|
||||||
@@ -40,6 +41,7 @@ export default function AdminImages() {
|
|||||||
previewUrl: img.previewUrl,
|
previewUrl: img.previewUrl,
|
||||||
uploadState: "idle",
|
uploadState: "idle",
|
||||||
uploadError: null,
|
uploadError: null,
|
||||||
|
optimizationSummary: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setCardState(state);
|
setCardState(state);
|
||||||
@@ -73,7 +75,10 @@ export default function AdminImages() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storagePath } = uploadData as { storagePath: string };
|
const { storagePath, optimization } = uploadData as {
|
||||||
|
storagePath: string;
|
||||||
|
optimization?: { summary: string; inRange: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
// Sauvegarde automatique en BDD
|
// Sauvegarde automatique en BDD
|
||||||
updateCard(key, { uploadState: "saving" });
|
updateCard(key, { uploadState: "saving" });
|
||||||
@@ -90,7 +95,12 @@ export default function AdminImages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Succès : mettre à jour l'état
|
// 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) =>
|
setImages((prev) =>
|
||||||
prev.map((img) =>
|
prev.map((img) =>
|
||||||
img.key === key
|
img.key === key
|
||||||
@@ -98,8 +108,11 @@ export default function AdminImages() {
|
|||||||
: img
|
: img
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setGlobalMessage({ type: "success", text: `"${key}" uploadé et sauvegardé !` });
|
const successMsg = optimization
|
||||||
setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000);
|
? `"${key}" sauvegardé — ${optimization.summary}`
|
||||||
|
: `"${key}" uploadé et sauvegardé !`;
|
||||||
|
setGlobalMessage({ type: "success", text: successMsg });
|
||||||
|
setTimeout(() => updateCard(key, { uploadState: "idle", optimizationSummary: null }), 5000);
|
||||||
} catch {
|
} catch {
|
||||||
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
|
updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" });
|
||||||
}
|
}
|
||||||
@@ -179,7 +192,7 @@ export default function AdminImages() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">Images du site</h1>
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,18 +314,23 @@ CREATE POLICY "service_role_full_access"
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<p className="text-orange text-xs font-medium">Upload en cours...</p>
|
<p className="text-orange text-xs font-medium">Optimisation et upload en cours...</p>
|
||||||
) : isSaving ? (
|
) : isSaving ? (
|
||||||
<p className="text-orange text-xs font-medium">Sauvegarde...</p>
|
<p className="text-orange text-xs font-medium">Sauvegarde...</p>
|
||||||
) : isDone ? (
|
) : isDone ? (
|
||||||
<p className="text-success text-xs font-medium">Fichier enregistré !</p>
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-success text-xs font-medium">Fichier enregistré !</p>
|
||||||
|
{state.optimizationSummary && (
|
||||||
|
<p className="text-white/40 text-xs">{state.optimizationSummary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-white/50 text-xs">
|
<p className="text-white/50 text-xs">
|
||||||
<span className="text-white/80 font-medium">Glissez une image</span> ou{" "}
|
<span className="text-white/80 font-medium">Glissez une image</span> ou{" "}
|
||||||
<span className="text-orange font-medium underline">parcourir</span>
|
<span className="text-orange font-medium underline">parcourir</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white/25 text-xs">JPEG · PNG · WebP · GIF · AVIF — max 5 Mo</p>
|
<p className="text-white/25 text-xs">JPEG · PNG · WebP · AVIF — converti en WebP, max 20 Mo</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
||||||
import type { Profile } from "@/types/database.types";
|
import type { Profile } from "@/types/database.types";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
const BUCKET = "private-gallery";
|
const BUCKET = "private-gallery";
|
||||||
|
|
||||||
// ── Types MIME autorisés → extension de stockage ───────────────────────────
|
// Taille cible : entre 300 Ko et 1 Mo
|
||||||
// L'extension est dérivée du MIME validé côté serveur, jamais du nom de fichier.
|
const TARGET_MAX_BYTES = 1_000_000; // 1 Mo
|
||||||
const MIME_TO_EXT: Record<string, string> = {
|
const TARGET_MIN_BYTES = 300_000; // 300 Ko (indicatif — on ne force pas l'inflation)
|
||||||
"image/jpeg": "jpg",
|
|
||||||
"image/png": "png",
|
// Paliers de qualité WebP : on descend jusqu'à rentrer sous 1 Mo
|
||||||
"image/webp": "webp",
|
const QUALITY_STEPS = [82, 72, 62, 50];
|
||||||
"image/gif": "gif",
|
|
||||||
"image/avif": "avif",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Signatures magic bytes ──────────────────────────────────────────────────
|
// ── Signatures magic bytes ──────────────────────────────────────────────────
|
||||||
// Permet de détecter le vrai format binaire indépendamment du Content-Type
|
// Permet de détecter le vrai format binaire indépendamment du Content-Type
|
||||||
@@ -33,7 +31,6 @@ const MAGIC_SIGNATURES: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
mime: "image/gif",
|
mime: "image/gif",
|
||||||
// GIF87a ou GIF89a
|
|
||||||
check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38,
|
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;
|
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() {
|
async function checkAdmin() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -78,7 +108,7 @@ async function checkAdmin() {
|
|||||||
return (profile as Pick<Profile, "is_admin"> | null)?.is_admin === true;
|
return (profile as Pick<Profile, "is_admin"> | 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) {
|
export async function POST(request: NextRequest) {
|
||||||
const isAdmin = await checkAdmin();
|
const isAdmin = await checkAdmin();
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -99,54 +129,50 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 });
|
return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1. Vérifier le type MIME déclaré ──────────────────────────────────────
|
// ── 1. Limite de taille brute (avant optimisation) ────────────────────────
|
||||||
if (!Object.hasOwn(MIME_TO_EXT, file.type)) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Fichier trop volumineux (max 20 Mo avant optimisation)" }, { status: 400 });
|
||||||
{ error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Limite de taille ───────────────────────────────────────────────────
|
// ── 2. Lire le contenu binaire ────────────────────────────────────────────
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Lire le contenu binaire ────────────────────────────────────────────
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = new Uint8Array(arrayBuffer);
|
const rawBuffer = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
// ── 4. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
|
// ── 3. Valider les magic bytes (anti-MIME spoofing) ───────────────────────
|
||||||
// On détecte le vrai format à partir des octets du fichier, pas du Content-Type
|
const detectedMime = detectMimeFromBytes(rawBuffer);
|
||||||
// envoyé par le client.
|
|
||||||
const detectedMime = detectMimeFromBytes(buffer);
|
|
||||||
if (!detectedMime) {
|
if (!detectedMime) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Le fichier ne correspond pas à un format image valide." },
|
{ error: "Le fichier ne correspond pas à un format image valide (JPEG, PNG, WebP, AVIF, GIF)." },
|
||||||
{ 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." },
|
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 4. Optimisation : conversion WebP + compression adaptative ────────────
|
||||||
|
let optimized: Awaited<ReturnType<typeof optimizeToWebP>>;
|
||||||
|
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 ───────────────────────────────────
|
// ── 5. Construire le chemin de stockage ───────────────────────────────────
|
||||||
// Extension toujours dérivée du MIME validé, jamais du nom de fichier fourni.
|
// Toujours .webp quelle que soit l'entrée (JPEG, PNG, AVIF…)
|
||||||
const ext = MIME_TO_EXT[detectedMime];
|
|
||||||
const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_");
|
const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_");
|
||||||
const filePath = `${sanitizedKey}/image.${ext}`;
|
const filePath = `${sanitizedKey}/image.webp`;
|
||||||
|
|
||||||
// ── 6. Upload vers Supabase Storage ──────────────────────────────────────
|
// ── 6. Upload vers Supabase Storage ──────────────────────────────────────
|
||||||
const adminClient = createAdminClient();
|
const adminClient = createAdminClient();
|
||||||
const { error } = await adminClient.storage
|
const { error } = await adminClient.storage
|
||||||
.from(BUCKET)
|
.from(BUCKET)
|
||||||
.upload(filePath, buffer, {
|
.upload(filePath, buffer, {
|
||||||
contentType: detectedMime,
|
contentType: "image/webp",
|
||||||
upsert: true,
|
upsert: true,
|
||||||
|
cacheControl: "public, max-age=31536000", // 1 an (CDN Supabase)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -157,5 +183,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = `storage:${filePath}`;
|
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})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -17,13 +17,14 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"resend": "^6.9.1",
|
"resend": "^6.9.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@sanity/vision": "^3.99.0",
|
"@sanity/vision": "^3.99.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.19.33",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -3231,7 +3232,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -10521,7 +10521,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -18452,7 +18451,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -18496,7 +18494,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,13 +18,14 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"resend": "^6.9.1",
|
"resend": "^6.9.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@sanity/vision": "^3.99.0",
|
"@sanity/vision": "^3.99.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.19.33",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
Reference in New Issue
Block a user