This commit is contained in:
gpatruno
2026-06-11 17:30:13 +02:00
parent 36b4acb355
commit e45e9f154e
108 changed files with 9163 additions and 167 deletions
+360
View File
@@ -0,0 +1,360 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { GalleryImage } from "@/generated/prisma/client";
import { isUploadAssetPath } from "@/lib/image-url";
import { ADMIN_API } from "@/lib/constants";
export function AdminGallery() {
const router = useRouter();
const [items, setItems] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
imageUrl: "",
alt: "",
sortOrder: "0",
});
const load = useCallback(async () => {
const res = await fetch(`${ADMIN_API}/gallery`, { credentials: "include" });
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const data = await res.json();
setItems(Array.isArray(data) ? data : []);
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function resetForm() {
setEditingId(null);
setForm({ imageUrl: "", alt: "", sortOrder: "0" });
}
function startEdit(g: GalleryImage) {
setEditingId(g.id);
setForm({
imageUrl: g.imageUrl,
alt: g.alt,
sortOrder: String(g.sortOrder),
});
}
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) setForm((f) => ({ ...f, imageUrl: data.url }));
} finally {
setUploading(false);
e.target.value = "";
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const sortOrder = parseInt(form.sortOrder, 10);
if (Number.isNaN(sortOrder)) return;
setSaving(true);
try {
const body = {
imageUrl: form.imageUrl.trim() || null,
alt: form.alt.trim(),
sortOrder,
};
if (editingId) {
const res = await fetch(`${ADMIN_API}/gallery/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
} else {
const res = await fetch(`${ADMIN_API}/gallery`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
}
resetForm();
await load();
} finally {
setSaving(false);
}
}
async function remove(id: string) {
if (!confirm("Supprimer cette photo de la galerie ?")) return;
const res = await fetch(`${ADMIN_API}/gallery/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (editingId === id) resetForm();
await load();
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
router.refresh();
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat">Galerie du site</h1>
<p className="text-sm text-muted">
Ordre daffichage (nombre le plus petit en premier). Sur la page
daccueil, jusquà 3 photos visibles ; défilement toutes les 5 s si
plus de 3 images.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
<form
onSubmit={onSubmit}
className="mb-10 rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]"
>
<h2 className="text-lg font-semibold text-chocolat">
{editingId ? "Modifier la photo" : "Ajouter une photo"}
</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Texte alternatif (accessibilité)
<input
value={form.alt}
onChange={(e) => setForm((f) => ({ ...f, alt: e.target.value }))}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Ordre (0 = premier)
<input
type="text"
inputMode="numeric"
value={form.sortOrder}
onChange={(e) =>
setForm((f) => ({ ...f, sortOrder: e.target.value }))
}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">Image</span>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={uploadFile}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">ou chemin / URL ci-dessous</span>
</div>
<input
type="text"
value={form.imageUrl}
onChange={(e) =>
setForm((f) => ({ ...f, imageUrl: e.target.value }))
}
required
placeholder="/uploads/… ou https://…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{form.imageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{form.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={form.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(form.imageUrl)}
className="object-cover"
sizes="144px"
/>
)}
</div>
<button
type="button"
onClick={() => setForm((f) => ({ ...f, imageUrl: "" }))}
className="text-sm text-red-700 underline"
>
Retirer limage
</button>
</div>
) : null}
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={saving || uploading}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingId
? "Mettre à jour"
: "Ajouter"}
</button>
{editingId && (
<button
type="button"
onClick={resetForm}
className="rounded-full border border-chocolat/20 px-6 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
)}
</div>
</form>
{loading ? (
<p className="text-muted">Chargement</p>
) : (
<ul className="space-y-3">
{items.map((g) => (
<li
key={g.id}
className="flex flex-col gap-3 rounded-2xl border border-chocolat/10 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex min-w-0 gap-3">
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{g.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={g.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={g.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(g.imageUrl)}
className="object-cover"
sizes="112px"
/>
)}
</div>
<div className="min-w-0">
<p className="font-medium text-chocolat">{g.alt}</p>
<p className="mt-1 truncate text-xs text-muted">{g.imageUrl}</p>
<p className="mt-1 text-sm text-muted">Ordre : {g.sortOrder}</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(g)}
className="rounded-full border border-chocolat/20 px-4 py-1.5 text-sm font-medium text-chocolat hover:bg-cream"
>
Modifier
</button>
<button
type="button"
onClick={() => remove(g.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-1.5 text-sm font-medium text-red-800 hover:bg-red-100"
>
Supprimer
</button>
</div>
</li>
))}
{items.length === 0 && (
<li className="rounded-2xl border border-dashed border-chocolat/20 p-8 text-center text-muted">
Aucune image. Ajoutez-en une ci-dessus.
</li>
)}
</ul>
)}
</div>
</div>
);
}