update
This commit is contained in:
@@ -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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user