From 4e6867b3654e2c10457036aa4104b2d8abbfc3a2 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:44:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Sprint=206=20=E2=80=94=20/admin/?= =?UTF-8?q?media=20gallery=20+=20theme=20write-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/media/page.tsx | 137 ++++++++++++++++++++++++++++++ src/app/admin/settings/actions.ts | 11 +++ src/lib/admin/media.ts | 60 +++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/app/admin/media/page.tsx create mode 100644 src/lib/admin/media.ts diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx new file mode 100644 index 0000000..36dc856 --- /dev/null +++ b/src/app/admin/media/page.tsx @@ -0,0 +1,137 @@ +import Link from "next/link"; +import { MediaType } from "@/generated/prisma/enums"; +import { getMediaStats, listMediaAdmin } from "@/lib/admin/media"; +import { StatusBadge } from "@/components/admin/StatusBadge"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams: Promise<{ q?: string; type?: string; carbetId?: string }>; +}; + +const TYPE_VALUES = new Set([MediaType.PHOTO, MediaType.VIDEO]); + +export default async function MediaAdminPage({ searchParams }: PageProps) { + const sp = await searchParams; + const filters = { + q: sp.q?.trim() || undefined, + type: TYPE_VALUES.has(sp.type ?? "") ? (sp.type as MediaType) : undefined, + carbetId: sp.carbetId || undefined, + }; + const [items, stats] = await Promise.all([listMediaAdmin(filters), getMediaStats()]); + const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); + + return ( +
+
+

Médias

+

+ {items.length} affiché{items.length > 1 ? "s" : ""} + {items.length === 200 ? " (limite atteinte — affinez les filtres)" : ""} +

+
+ +
+ + + + + +
+ +
+ + + + {(filters.q || filters.type || filters.carbetId) ? ( + + Réinit. + + ) : null} +
+ + {items.length === 0 ? ( +
+ Aucun média ne correspond aux filtres. +
+ ) : ( +
+ {items.map((m) => ( + +
+ {m.type === MediaType.PHOTO ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.s3Key} + ) : ( +
+ )} + + {m.type} + +
+
+
+ {m.carbet.title} + +
+
+ {m.s3Key} + {dateFmt.format(m.createdAt)} +
+
+ + ))} +
+ )} +
+ ); +} + +function Stat({ + label, + value, + tone = "neutral", +}: { + label: string; + value: number; + tone?: "neutral" | "warn"; +}) { + return ( +
0 ? "border-amber-300" : "border-zinc-200") + } + > +
{label}
+
0 ? "text-amber-700" : "text-zinc-900")}> + {value} +
+
+ ); +} diff --git a/src/app/admin/settings/actions.ts b/src/app/admin/settings/actions.ts index c112f4a..7da4291 100644 --- a/src/app/admin/settings/actions.ts +++ b/src/app/admin/settings/actions.ts @@ -7,6 +7,7 @@ import { UserRole } from "@/generated/prisma/enums"; import { requireRole } from "@/lib/authorization"; import { recordAudit } from "@/lib/admin/audit"; import { setSetting } from "@/lib/admin/settings"; +import { togglePlugin } from "@/lib/plugins/server"; const platformSchema = z.object({ name: z.string().trim().min(2).max(80), @@ -66,8 +67,18 @@ export async function saveThemeSettingsAction(fd: FormData) { } const who = await actor(); await setSetting("theme", parsed.data, who); + + // Le rendu du site public est piloté par l'état des plugins thème. + // On synchronise : un seul plugin actif (ou aucun pour "default"). + const wantAquarelle = parsed.data.active === "theme-aquarelle"; + const wantGuyane = parsed.data.active === "theme-guyane"; + await togglePlugin("theme-aquarelle", wantAquarelle); + await togglePlugin("theme-guyane", wantGuyane); + await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data }); revalidatePath("/admin/settings"); + revalidatePath("/admin/plugins"); + revalidatePath("/"); return { ok: true as const }; } diff --git a/src/lib/admin/media.ts b/src/lib/admin/media.ts new file mode 100644 index 0000000..f96654e --- /dev/null +++ b/src/lib/admin/media.ts @@ -0,0 +1,60 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { MediaType } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type AdminMediaFilters = { + q?: string; + type?: MediaType; + carbetId?: string; +}; + +export type AdminMediaListItem = { + id: string; + type: MediaType; + s3Key: string; + s3Url: string; + sortOrder: number; + createdAt: Date; + carbet: { id: string; title: string; slug: string; status: string }; +}; + +export async function listMediaAdmin(filters: AdminMediaFilters = {}): Promise { + const where: Prisma.MediaWhereInput = {}; + if (filters.q) { + where.OR = [ + { s3Key: { contains: filters.q, mode: "insensitive" } }, + { carbet: { title: { contains: filters.q, mode: "insensitive" } } }, + { carbet: { slug: { contains: filters.q, mode: "insensitive" } } }, + ]; + } + if (filters.type) where.type = filters.type; + if (filters.carbetId) where.carbetId = filters.carbetId; + + return prisma.media.findMany({ + where, + orderBy: [{ createdAt: "desc" }], + take: 200, + select: { + id: true, + type: true, + s3Key: true, + s3Url: true, + sortOrder: true, + createdAt: true, + carbet: { select: { id: true, title: true, slug: true, status: true } }, + }, + }); +} + +export async function getMediaStats() { + const [total, photo, video, carbetsWithMedia, carbetsWithoutMedia] = await Promise.all([ + prisma.media.count(), + prisma.media.count({ where: { type: MediaType.PHOTO } }), + prisma.media.count({ where: { type: MediaType.VIDEO } }), + prisma.carbet.count({ where: { media: { some: {} } } }), + prisma.carbet.count({ where: { media: { none: {} } } }), + ]); + return { total, photo, video, carbetsWithMedia, carbetsWithoutMedia }; +}