Files
gaufrementbon/src/components/admin/AdminGallery.tsx
T
gpatruno e45e9f154e update
2026-06-11 17:30:13 +02:00

361 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}