From 7a46501ba3ab5b085d474c84caa14dc3edb46b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 06:16:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20upload=20images=20vers=20bucket=20Supab?= =?UTF-8?q?ase=20priv=C3=A9=20avec=20Signed=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvelle route POST /api/admin/upload : upload multipart vers le bucket private-gallery, validation MIME + taille (max 5 Mo), retourne storage:path - lib/site-images.ts : détecte le préfixe "storage:" et génère une Signed URL temporaire (60 min) côté serveur avant chaque rendu de page - GET /api/admin/site-images : résout aussi les signed URLs pour les previews admin (champ previewUrl distinct de url brute) - PUT /api/admin/site-images : accepte désormais les chemins "storage:..." en plus des URLs externes - Page admin images : drag & drop + input file avec upload automatique + sauvegarde en BDD, badge "bucket privé", instructions SQL pour créer la table et la policy du bucket private-gallery https://claude.ai/code/session_01PzA98VhLMmsHpzs7gnLHGs --- app/admin/images/page.tsx | 374 ++++++++++++++++++++++------- app/api/admin/site-images/route.ts | 61 +++-- app/api/admin/upload/route.ts | 85 +++++++ lib/site-images.ts | 42 +++- 4 files changed, 451 insertions(+), 111 deletions(-) create mode 100644 app/api/admin/upload/route.ts diff --git a/app/admin/images/page.tsx b/app/admin/images/page.tsx index 198d59a..07025b9 100644 --- a/app/admin/images/page.tsx +++ b/app/admin/images/page.tsx @@ -1,60 +1,169 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; interface SiteImage { key: string; - url: string; + url: string; // valeur brute (ex: "storage:hero_portrait/image.jpg" ou "https://...") + previewUrl: string; // URL résolvée pour l'affichage label: string; updated_at: string | null; } +type UploadState = "idle" | "uploading" | "saving" | "done" | "error"; + +interface ImageCardState { + editUrl: string; // valeur brute en cours d'édition + previewUrl: string; // URL pour l'aperçu + uploadState: UploadState; + uploadError: string | null; +} + export default function AdminImages() { const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(null); - const [editUrls, setEditUrls] = useState>({}); - const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [cardState, setCardState] = useState>({}); + const [globalMessage, setGlobalMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [draggingOver, setDraggingOver] = useState(null); + const fileInputRefs = useRef>({}); useEffect(() => { fetch("/api/admin/site-images") .then((r) => r.json()) .then((data) => { - setImages(data.images || []); - const urls: Record = {}; - for (const img of data.images || []) { - urls[img.key] = img.url; + const imgs: SiteImage[] = data.images || []; + setImages(imgs); + const state: Record = {}; + for (const img of imgs) { + state[img.key] = { + editUrl: img.url, + previewUrl: img.previewUrl, + uploadState: "idle", + uploadError: null, + }; } - setEditUrls(urls); + setCardState(state); }) .finally(() => setLoading(false)); }, []); - const handleSave = async (key: string) => { - setSaving(key); - setMessage(null); - try { - const res = await fetch("/api/admin/site-images", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key, url: editUrls[key] }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ type: "success", text: `Image "${key}" mise à jour !` }); + const updateCard = useCallback((key: string, patch: Partial) => { + setCardState((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); + }, []); + + // Upload d'un fichier + sauvegarde automatique + const handleFile = useCallback( + async (key: string, file: File) => { + updateCard(key, { uploadState: "uploading", uploadError: null }); + + // Aperçu local immédiat (object URL) + const localPreview = URL.createObjectURL(file); + updateCard(key, { previewUrl: localPreview }); + + try { + const form = new FormData(); + form.append("file", file); + form.append("key", key); + + const uploadRes = await fetch("/api/admin/upload", { method: "POST", body: form }); + const uploadData = await uploadRes.json(); + + if (!uploadRes.ok) { + updateCard(key, { uploadState: "error", uploadError: uploadData.error || "Erreur upload" }); + return; + } + + const { storagePath } = uploadData as { storagePath: string }; + + // Sauvegarde automatique en BDD + updateCard(key, { uploadState: "saving" }); + const saveRes = await fetch("/api/admin/site-images", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, url: storagePath }), + }); + const saveData = await saveRes.json(); + + if (!saveRes.ok) { + updateCard(key, { uploadState: "error", uploadError: saveData.error || "Erreur sauvegarde" }); + return; + } + + // Succès : mettre à jour l'état + updateCard(key, { editUrl: storagePath, uploadState: "done", uploadError: null }); setImages((prev) => prev.map((img) => - img.key === key ? { ...img, url: editUrls[key], updated_at: new Date().toISOString() } : img + img.key === key + ? { ...img, url: storagePath, previewUrl: localPreview, updated_at: new Date().toISOString() } + : img ) ); - } else { - setMessage({ type: "error", text: data.error || "Erreur" }); + setGlobalMessage({ type: "success", text: `"${key}" uploadé et sauvegardé !` }); + setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000); + } catch { + updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" }); } - } catch { - setMessage({ type: "error", text: "Erreur réseau" }); - } - setSaving(null); - }; + }, + [updateCard] + ); + + // Sauvegarde manuelle d'une URL externe + const handleSaveUrl = useCallback( + async (key: string) => { + const url = cardState[key]?.editUrl; + if (!url) return; + + updateCard(key, { uploadState: "saving", uploadError: null }); + setGlobalMessage(null); + + try { + const res = await fetch("/api/admin/site-images", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, url }), + }); + const data = await res.json(); + + if (res.ok) { + updateCard(key, { previewUrl: url, uploadState: "done" }); + setImages((prev) => + prev.map((img) => + img.key === key + ? { ...img, url, previewUrl: url, updated_at: new Date().toISOString() } + : img + ) + ); + setGlobalMessage({ type: "success", text: `"${key}" mis à jour !` }); + setTimeout(() => updateCard(key, { uploadState: "idle" }), 3000); + } else { + updateCard(key, { uploadState: "error", uploadError: data.error || "Erreur" }); + } + } catch { + updateCard(key, { uploadState: "error", uploadError: "Erreur réseau" }); + } + }, + [cardState, updateCard] + ); + + const handleDrop = useCallback( + (key: string, e: React.DragEvent) => { + e.preventDefault(); + setDraggingOver(null); + const file = e.dataTransfer.files[0]; + if (file) handleFile(key, file); + }, + [handleFile] + ); + + const handleFileInputChange = useCallback( + (key: string, e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(key, file); + // Reset input pour permettre de re-sélectionner le même fichier + e.target.value = ""; + }, + [handleFile] + ); if (loading) { return ( @@ -70,91 +179,178 @@ export default function AdminImages() {

Images du site

- Changez les URLs des images affichées sur le site. Collez un lien Noelshack, Unsplash, ou tout autre hébergeur d'images. + Uploadez vos fichiers directement dans le bucket privé Supabase, ou collez une URL externe.

- {message && ( + {globalMessage && (
- {message.text} + {globalMessage.text}
)} {/* Info SQL */} -
-

- Si la sauvegarde échoue, créez la table dans Supabase (SQL Editor) : -

-
+      
+
+

+ 1. Créer la table site_images dans Supabase (SQL Editor) : +

+
 {`CREATE TABLE site_images (
   key TEXT PRIMARY KEY,
   url TEXT NOT NULL,
   label TEXT,
   updated_at TIMESTAMPTZ DEFAULT NOW()
 );`}
-        
+
+
+
+

+ 2. Créer le bucket private-gallery (Storage → New bucket, décocher "Public") puis appliquer cette policy : +

+
+{`-- Autoriser le service role à tout faire (uploads serveur)
+CREATE POLICY "service_role_full_access"
+  ON storage.objects FOR ALL
+  TO service_role
+  USING (bucket_id = 'private-gallery')
+  WITH CHECK (bucket_id = 'private-gallery');`}
+          
+
- {images.map((img) => ( -
-
- {/* Preview */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {img.label} { - (e.target as HTMLImageElement).src = - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23666' viewBox='0 0 24 24'%3E%3Cpath d='M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z'/%3E%3C/svg%3E"; - }} - /> -
+ {images.map((img) => { + const state = cardState[img.key]; + if (!state) return null; + const isUploading = state.uploadState === "uploading"; + const isSaving = state.uploadState === "saving"; + const isBusy = isUploading || isSaving; + const isDone = state.uploadState === "done"; + const isError = state.uploadState === "error"; + const isStoredInBucket = state.editUrl.startsWith("storage:"); + const urlChanged = state.editUrl !== img.url; - {/* Form */} -
-
-

{img.label}

- {img.key} -
- {img.updated_at && ( -

- Modifié le{" "} - {new Date(img.updated_at).toLocaleDateString("fr-FR", { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} -

- )} -
- setEditUrls((prev) => ({ ...prev, [img.key]: e.target.value }))} - placeholder="https://..." - className="flex-1 px-4 py-2.5 bg-black/30 border border-dark-border rounded-xl text-white text-sm placeholder:text-white/20 focus:border-orange focus:ring-1 focus:ring-orange outline-none" + return ( +
+
+ {/* Preview */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {img.label} { + (e.target as HTMLImageElement).src = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23666' viewBox='0 0 24 24'%3E%3Cpath d='M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z'/%3E%3C/svg%3E"; + }} /> -
+ + {/* Contenu */} +
+
+

{img.label}

+ {img.key} + {isStoredInBucket && ( + + bucket privé + + )} +
+ + {img.updated_at && ( +

+ Modifié le{" "} + {new Date(img.updated_at).toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+ )} + + {/* Zone drag & drop */} +
{ + e.preventDefault(); + setDraggingOver(img.key); + }} + onDragLeave={() => setDraggingOver(null)} + onDrop={(e) => handleDrop(img.key, e)} + onClick={() => fileInputRefs.current[img.key]?.click()} > - {saving === img.key ? "..." : "Sauver"} - + { fileInputRefs.current[img.key] = el; }} + onChange={(e) => handleFileInputChange(img.key, e)} + /> + + {isUploading ? ( +

Upload en cours...

+ ) : isSaving ? ( +

Sauvegarde...

+ ) : isDone ? ( +

Fichier enregistré !

+ ) : ( +
+

+ Glissez une image ou{" "} + parcourir +

+

JPEG · PNG · WebP · GIF · AVIF — max 5 Mo

+
+ )} +
+ + {isError && ( +

{state.uploadError}

+ )} + + {/* URL externe (fallback) */} +
+ + updateCard(img.key, { editUrl: e.target.value, previewUrl: e.target.value }) + } + placeholder="Ou coller une URL externe (https://...)" + disabled={isBusy} + className="flex-1 px-4 py-2.5 bg-black/30 border border-dark-border rounded-xl text-white text-sm placeholder:text-white/20 focus:border-orange focus:ring-1 focus:ring-orange outline-none disabled:opacity-40" + /> + +
-
- ))} + ); + })}
); diff --git a/app/api/admin/site-images/route.ts b/app/api/admin/site-images/route.ts index 7f77b5a..81c4d05 100644 --- a/app/api/admin/site-images/route.ts +++ b/app/api/admin/site-images/route.ts @@ -10,6 +10,9 @@ interface SiteImageRow { updated_at: string; } +const BUCKET = "private-gallery"; +const SIGNED_URL_TTL = 3600; // 1 heure + async function checkAdmin() { const supabase = await createClient(); const { @@ -27,6 +30,21 @@ async function checkAdmin() { return (profile as Pick | null)?.is_admin === true; } +/** + * Génère une URL de prévisualisation pour l'admin. + * Pour les chemins "storage:", crée une Signed URL temporaire. + * Pour les URLs externes, retourne l'URL telle quelle. + */ +async function resolvePreviewUrl(rawUrl: string): Promise { + if (!rawUrl.startsWith("storage:")) return rawUrl; + const filePath = rawUrl.slice("storage:".length); + const adminClient = createAdminClient(); + const { data } = await adminClient.storage + .from(BUCKET) + .createSignedUrl(filePath, SIGNED_URL_TTL); + return data?.signedUrl ?? rawUrl; +} + // GET - Récupérer toutes les images export async function GET() { const isAdmin = await checkAdmin(); @@ -35,20 +53,25 @@ export async function GET() { } try { - const supabase = createAdminClient(); - const { data } = await supabase.from("site_images").select("*"); + const adminClient = createAdminClient(); + const { data } = await adminClient.from("site_images").select("*"); const rows = (data ?? []) as unknown as SiteImageRow[]; - // Merge defaults avec les valeurs en base - const images = Object.entries(DEFAULT_IMAGES).map(([key, def]) => { - const saved = rows.find((d) => d.key === key); - return { - key, - url: saved?.url || def.url, - label: def.label, - updated_at: saved?.updated_at || null, - }; - }); + // Merge defaults avec les valeurs en base, résoudre les signed URLs en parallèle + const images = await Promise.all( + Object.entries(DEFAULT_IMAGES).map(async ([key, def]) => { + const saved = rows.find((d) => d.key === key); + const rawUrl = saved?.url || def.url; + const previewUrl = await resolvePreviewUrl(rawUrl); + return { + key, + url: rawUrl, // valeur brute stockée (ex: "storage:hero_portrait/image.jpg") + previewUrl, // URL résolvée pour l'affichage dans le navigateur + label: def.label, + updated_at: saved?.updated_at || null, + }; + }) + ); return NextResponse.json({ images }); } catch { @@ -56,6 +79,7 @@ export async function GET() { const images = Object.entries(DEFAULT_IMAGES).map(([key, def]) => ({ key, url: def.url, + previewUrl: def.url, label: def.label, updated_at: null, })); @@ -77,11 +101,14 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: "key et url requis" }, { status: 400 }); } - // Vérifier que l'URL est valide - try { - new URL(url); - } catch { - return NextResponse.json({ error: "URL invalide" }, { status: 400 }); + // Accepter soit une URL externe (https://...) soit un chemin storage (storage:...) + const isStoragePath = url.startsWith("storage:"); + if (!isStoragePath) { + try { + new URL(url); + } catch { + return NextResponse.json({ error: "URL invalide" }, { status: 400 }); + } } const success = await updateSiteImage(key, url); diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts new file mode 100644 index 0000000..1bbe78d --- /dev/null +++ b/app/api/admin/upload/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient, createAdminClient } from "@/lib/supabase/server"; +import type { Profile } from "@/types/database.types"; + +const BUCKET = "private-gallery"; + +async function checkAdmin() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return false; + + const adminClient = createAdminClient(); + const { data: profile } = await adminClient + .from("profiles") + .select("is_admin") + .eq("id", user.id) + .single(); + + return (profile as Pick | null)?.is_admin === true; +} + +// POST - Upload un fichier dans le bucket private-gallery +export async function POST(request: NextRequest) { + const isAdmin = await checkAdmin(); + if (!isAdmin) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return NextResponse.json({ error: "Corps de requête invalide" }, { status: 400 }); + } + + const file = formData.get("file") as File | null; + const imageKey = formData.get("key") as string | null; + + if (!file || !imageKey) { + return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 }); + } + + // Valider le type MIME + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." }, + { status: 400 } + ); + } + + // Limiter à 5 Mo + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 }); + } + + // Construire le chemin : ex. "hero/image.jpg" + const ext = file.name.split(".").pop() ?? "jpg"; + const sanitizedKey = imageKey.replace(/[^a-zA-Z0-9_-]/g, "_"); + const filePath = `${sanitizedKey}/image.${ext}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer); + + const adminClient = createAdminClient(); + const { error } = await adminClient.storage + .from(BUCKET) + .upload(filePath, buffer, { + contentType: file.type, + upsert: true, + }); + + if (error) { + return NextResponse.json( + { error: `Erreur upload Supabase : ${error.message}` }, + { status: 500 } + ); + } + + // Retourner le chemin avec préfixe "storage:" + const storagePath = `storage:${filePath}`; + return NextResponse.json({ storagePath, filePath }); +} diff --git a/lib/site-images.ts b/lib/site-images.ts index d812891..ee7c40a 100644 --- a/lib/site-images.ts +++ b/lib/site-images.ts @@ -43,8 +43,36 @@ export const DEFAULT_IMAGES: Record = { }, }; +const STORAGE_PREFIX = "storage:"; +const BUCKET = "private-gallery"; +// Durée de validité des Signed URLs : 1 heure +const SIGNED_URL_TTL = 3600; + +/** + * Résout une valeur stockée en BDD vers une URL publique. + * Si la valeur commence par "storage:", génère une Signed URL temporaire (60 min) + * depuis le bucket privé Supabase. + * Sinon, retourne la valeur telle quelle (URL externe). + */ +async function resolveUrl(raw: string): Promise { + if (!raw.startsWith(STORAGE_PREFIX)) return raw; + + const filePath = raw.slice(STORAGE_PREFIX.length); // ex: "hero_portrait/image.jpg" + const supabase = createAdminClient(); + const { data, error } = await supabase.storage + .from(BUCKET) + .createSignedUrl(filePath, SIGNED_URL_TTL); + + if (error || !data?.signedUrl) { + // En cas d'erreur, on renvoie l'URL brute (le placeholder s'affichera) + return raw; + } + return data.signedUrl; +} + /** * Récupère toutes les images du site depuis Supabase. + * Les valeurs "storage:..." sont converties en Signed URLs à la volée. * Fallback sur les valeurs par défaut si la table n'existe pas. */ export async function getSiteImages(): Promise> { @@ -61,11 +89,14 @@ export async function getSiteImages(): Promise> { const rows = (data ?? []) as unknown as Pick[]; if (!error) { - for (const row of rows) { - if (row.url) { - result[row.key] = row.url; - } - } + // Résoudre toutes les URLs en parallèle (signed URLs pour les paths storage:) + await Promise.all( + rows.map(async (row) => { + if (row.url) { + result[row.key] = await resolveUrl(row.url); + } + }) + ); } } catch { // Table n'existe pas encore, on utilise les defaults @@ -76,6 +107,7 @@ export async function getSiteImages(): Promise> { /** * Met à jour une image du site. + * Accepte une URL externe (https://...) ou un chemin storage (storage:...). */ export async function updateSiteImage(key: string, url: string): Promise { try {