From 12576cc113c45977377c6a1fae7ef9e7bc5f2056 Mon Sep 17 00:00:00 2001
From: remco
Date: Wed, 1 Jul 2026 17:00:35 +0200
Subject: [PATCH] feat: media library with image picker for article forms,
browse/upload from folder
---
src/actions/admin-media.ts | 66 ++++++++++++
src/app/admin/articles/[id]/page.tsx | 25 +++--
src/app/admin/articles/new/page.tsx | 10 +-
src/app/admin/layout.tsx | 3 +-
src/app/admin/media/page.tsx | 17 ++++
src/app/api/media/route.ts | 23 +++++
src/components/admin/article-form.tsx | 51 ++++++++++
src/components/admin/media-grid.tsx | 78 +++++++++++++++
src/components/admin/media-picker.tsx | 138 ++++++++++++++++++++++++++
9 files changed, 392 insertions(+), 19 deletions(-)
create mode 100644 src/actions/admin-media.ts
create mode 100644 src/app/admin/media/page.tsx
create mode 100644 src/app/api/media/route.ts
create mode 100644 src/components/admin/article-form.tsx
create mode 100644 src/components/admin/media-grid.tsx
create mode 100644 src/components/admin/media-picker.tsx
diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts
new file mode 100644
index 00000000..c88936f3
--- /dev/null
+++ b/src/actions/admin-media.ts
@@ -0,0 +1,66 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { writeFile, mkdir } from "fs/promises";
+import path from "path";
+import { requireStaff } from "@/lib/admin/guard";
+
+const MEDIA_DIR = "public/assets/images/media";
+const MAX_SIZE = 5 * 1024 * 1024; // 5MB
+const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
+
+export async function uploadMedia(formData: FormData): Promise {
+ await requireStaff();
+ const file = formData.get("file") as File | null;
+ if (!file || file.size === 0) return;
+ if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
+ if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
+
+ const dir = path.join(process.cwd(), MEDIA_DIR);
+ await mkdir(dir, { recursive: true });
+
+ const ext = file.name.split(".").pop() ?? "png";
+ const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
+ const bytes = await file.arrayBuffer();
+ await writeFile(path.join(dir, name), Buffer.from(bytes));
+
+ revalidatePath("/api/media");
+ revalidatePath("/admin/media");
+}
+
+export async function deleteMedia(name: string): Promise {
+ await requireStaff();
+ const { unlink } = await import("fs/promises");
+ const dir = path.join(process.cwd(), MEDIA_DIR);
+ const filePath = path.join(dir, name);
+ // Prevent path traversal
+ if (name.includes("..") || name.includes("/")) return;
+ try {
+ await unlink(filePath);
+ } catch {
+ // File may not exist
+ }
+ revalidatePath("/api/media");
+ revalidatePath("/admin/media");
+}
+
+export async function uploadMediaAndReturn(formData: FormData): Promise {
+ await requireStaff();
+ const file = formData.get("file") as File | null;
+ if (!file || file.size === 0) return "";
+ if (file.size > MAX_SIZE) return "";
+ if (!ALLOWED.includes(file.type)) return "";
+
+ const dir = path.join(process.cwd(), MEDIA_DIR);
+ await mkdir(dir, { recursive: true });
+
+ const ext = file.name.split(".").pop() ?? "png";
+ const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
+ const bytes = await file.arrayBuffer();
+ await writeFile(path.join(dir, name), Buffer.from(bytes));
+
+ revalidatePath("/api/media");
+ revalidatePath("/admin/media");
+ return `/assets/images/media/${name}`;
+}
diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx
index e7411be2..6882045e 100644
--- a/src/app/admin/articles/[id]/page.tsx
+++ b/src/app/admin/articles/[id]/page.tsx
@@ -1,7 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
-import { RichText } from "@/components/admin/rich-text";
+import { ArticleForm } from "@/components/admin/article-form";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -14,7 +14,7 @@ export default async function EditArticle({
const { id } = await params;
const article = await prisma.websiteArticles.findUnique({
where: { id: BigInt(id) },
- select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
+ select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
}).catch(() => null);
if (!article) notFound();
@@ -24,14 +24,19 @@ export default async function EditArticle({
← Articles
Edit article
-
+ {
+ "use server";
+ fd.set("id", String(article.id));
+ await updateArticle(fd);
+ }}
+ defaultValues={{
+ title: article.title,
+ image: article.image,
+ shortStory: article.shortStory,
+ fullStory: article.fullStory,
+ }}
+ />
+
);
}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 38eb774c..03603647 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -71,7 +71,8 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[]
{ href: "/admin/housekeeping", label: "Housekeeping" },
{ href: "/admin/rooms", label: "Rooms" },
{ href: "/admin/email-templates", label: "Email" },
- { href: "/admin/theme", label: "Theme" },
+ { href: "/admin/theme", label: "Theme" },
+ { href: "/admin/media", label: "Media" },
{ href: "/admin/settings", label: "Settings" },
],
},
diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx
new file mode 100644
index 00000000..a9df4c73
--- /dev/null
+++ b/src/app/admin/media/page.tsx
@@ -0,0 +1,17 @@
+import Link from "next/link";
+import { AdminMediaGrid } from "@/components/admin/media-grid";
+
+export default function AdminMediaPage() {
+ return (
+
+
+
Media Library
+ ← Articles
+
+
+ Upload and manage images for articles and other content.
+
+
+
+ );
+}
diff --git a/src/app/api/media/route.ts b/src/app/api/media/route.ts
new file mode 100644
index 00000000..76aacf3d
--- /dev/null
+++ b/src/app/api/media/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import { readdirSync, existsSync } from "fs";
+import path from "path";
+
+export const dynamic = "force-dynamic";
+
+const MEDIA_DIR = "assets/images/media";
+
+export async function GET() {
+ const dir = path.join(process.cwd(), "public", MEDIA_DIR);
+ if (!existsSync(dir)) {
+ return NextResponse.json({ files: [] });
+ }
+ const files = readdirSync(dir)
+ .filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
+ .map((f) => ({
+ name: f,
+ url: `/${MEDIA_DIR}/${f}`,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return NextResponse.json({ files });
+}
diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx
new file mode 100644
index 00000000..180f2078
--- /dev/null
+++ b/src/components/admin/article-form.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useState } from "react";
+import { MediaPicker } from "./media-picker";
+import { RichText } from "./rich-text";
+
+export function ArticleForm({
+ action,
+ defaultValues,
+}: {
+ action: (formData: FormData) => Promise;
+ defaultValues?: { title?: string; image?: string; shortStory?: string; fullStory?: string };
+}) {
+ const [image, setImage] = useState(defaultValues?.image ?? "");
+ const [imageError, setImageError] = useState(false);
+
+ return (
+
+ );
+}
diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx
new file mode 100644
index 00000000..36f6ae55
--- /dev/null
+++ b/src/components/admin/media-grid.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { uploadMedia } from "@/actions/admin-media";
+
+type MediaFile = { name: string; url: string };
+
+export function AdminMediaGrid() {
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch("/api/media");
+ const data = await res.json();
+ setFiles(data.files ?? []);
+ } catch { /* ignore */ }
+ setLoading(false);
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ async function handleUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ const fd = new FormData();
+ fd.set("file", file);
+ try {
+ await uploadMedia(fd);
+ await load();
+ } catch { /* ignore */ }
+ setUploading(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+
+ function copyUrl(url: string) {
+ navigator.clipboard.writeText(window.location.origin + url).catch(() => {});
+ }
+
+ return (
+
+
+ {files.length} image{files.length !== 1 ? "s" : ""}
+
+
+
+ {loading ? (
+
Loading…
+ ) : files.length === 0 ? (
+
No images yet. Upload one above.
+ ) : (
+
+ {files.map((f) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ {f.name}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/media-picker.tsx b/src/components/admin/media-picker.tsx
new file mode 100644
index 00000000..1d2bc249
--- /dev/null
+++ b/src/components/admin/media-picker.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { uploadMediaAndReturn } from "@/actions/admin-media";
+
+type MediaFile = { name: string; url: string };
+
+export function MediaPicker({
+ onSelect,
+ current,
+}: {
+ onSelect: (url: string) => void;
+ current?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch("/api/media");
+ const data = await res.json();
+ setFiles(data.files ?? []);
+ } catch {
+ /* ignore */
+ }
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ if (open) load();
+ }, [open, load]);
+
+ async function handleUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ const fd = new FormData();
+ fd.set("file", file);
+ const url = await uploadMediaAndReturn(fd);
+ if (url) {
+ await load();
+ onSelect(url);
+ setOpen(false);
+ }
+ setUploading(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+
+ return (
+ <>
+
+
+ {current && (
+
+ {current.split("/").pop()}
+
+ )}
+
+
+ {open && (
+ setOpen(false)}
+ >
+
e.stopPropagation()}
+ style={{
+ background: "var(--color-surface)", color: "var(--color-text)",
+ borderRadius: 14, width: "min(90vw, 680px)", maxHeight: "80vh",
+ display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.25)",
+ }}
+ >
+
+
Media Library
+
+
+
+
+
+
+ {loading ? (
+
Loading…
+ ) : files.length === 0 ? (
+
+ No images yet. Upload one above.
+
+ ) : (
+
+ {files.map((f) => (
+
+ ))}
+
+ )}
+
+
+
+ )}
+ >
+ );
+}