"use client"; import { useCallback, useEffect, useRef, useState } from "react"; interface SiteImage { key: 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; optimizationSummary: string | null; // ex: "2 400 Ko → 680 Ko (WebP q82)" } export default function AdminImages() { const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); 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) => { 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, optimizationSummary: null, }; } setCardState(state); }) .finally(() => setLoading(false)); }, []); 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, optimization } = uploadData as { storagePath: string; optimization?: { summary: string; inRange: boolean }; }; // 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, optimizationSummary: optimization?.summary ?? null, }); setImages((prev) => prev.map((img) => img.key === key ? { ...img, url: storagePath, previewUrl: localPreview, updated_at: new Date().toISOString() } : img ) ); 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" }); } }, [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 (

Images du site

Chargement...

); } return (

Images du site

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.

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

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) => { 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; 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()} > { fileInputRefs.current[img.key] = el; }} onChange={(e) => handleFileInputChange(img.key, e)} /> {isUploading ? (

Optimisation et upload en cours...

) : isSaving ? (

Sauvegarde...

) : isDone ? (

Fichier enregistré !

{state.optimizationSummary && (

{state.optimizationSummary}

)}
) : (

Glissez une image ou{" "} parcourir

JPEG · PNG · WebP · AVIF — converti en WebP, max 20 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" />
); })}
); }