361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
"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 l’envoi");
|
||
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 d’affichage (nombre le plus petit en premier). Sur la page
|
||
d’accueil, 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 l’image
|
||
</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>
|
||
);
|
||
}
|