feat: media library with image picker for article forms, browse/upload from folder

This commit is contained in:
remco committed 2026-07-01 17:00:35 +02:00
1 parent d35c495539
commit 12576cc113
9 files changed
+392 -19

No files matched your search

+66
View File
@@ -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<void> {
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<void> {
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<string> {
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}`;
}
+15 -10
View File
@@ -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({
<Link href="/admin/articles">← Articles</Link>
</p>
<h1>Edit article</h1>
<form action={updateArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<input name="title" defaultValue={article.title} required />
<input name="image" defaultValue={article.image} placeholder="Image URL" />
<textarea name="shortStory" defaultValue={article.shortStory} rows={2} />
<RichText name="fullStory" defaultValue={article.fullStory} rows={10} />
<button type="submit">Save</button>
</form>
<ArticleForm
action={async (fd) => {
"use server";
fd.set("id", String(article.id));
await updateArticle(fd);
}}
defaultValues={{
title: article.title,
image: article.image,
shortStory: article.shortStory,
fullStory: article.fullStory,
}}
/>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<button type="submit" className="btn btn-primary">Delete article</button>
+2 -8
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { createArticle } from "@/actions/admin-articles";
import { RichText } from "@/components/admin/rich-text";
import { ArticleForm } from "@/components/admin/article-form";
export default function NewArticle() {
return (
@@ -9,13 +9,7 @@ export default function NewArticle() {
<Link href="/admin/articles">← Articles</Link>
</p>
<h1>New article</h1>
<form action={createArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="title" placeholder="Title" required />
<input name="image" placeholder="Image URL" />
<textarea name="shortStory" placeholder="Short story (summary)" rows={2} />
<RichText name="fullStory" placeholder="Full story" rows={10} />
<button type="submit">Create</button>
</form>
<ArticleForm action={createArticle} />
</main>
);
}
+2 -1
View File
@@ -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" },
],
},
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
import { AdminMediaGrid } from "@/components/admin/media-grid";
export default function AdminMediaPage() {
return (
<main>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1>Media Library</h1>
<Link href="/admin/articles" className="muted" style={{ fontSize: "0.88rem" }}>← Articles</Link>
</div>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Upload and manage images for articles and other content.
</p>
<AdminMediaGrid />
</main>
);
}
+23
View File
@@ -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 });
}
+51
View File
@@ -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<void>;
defaultValues?: { title?: string; image?: string; shortStory?: string; fullStory?: string };
}) {
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
return (
<form action={action} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="title" defaultValue={defaultValues?.title ?? ""} placeholder="Title" required />
<div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }}>
<input
name="image"
value={image}
onChange={(e) => { setImage(e.target.value); setImageError(false); }}
placeholder="Image URL"
style={{ flex: 1 }}
/>
<MediaPicker onSelect={(url) => { setImage(url); setImageError(false); }} current={image} />
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
style={{ marginTop: "0.4rem", maxHeight: 120, borderRadius: 6, objectFit: "cover" }}
/>
) : null}
{imageError ? (
<p className="muted" style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}>Image not found — enter a valid URL or browse.</p>
) : null}
</div>
<textarea name="shortStory" defaultValue={defaultValues?.shortStory ?? ""} placeholder="Short story (summary)" rows={2} />
<RichText name="fullStory" defaultValue={defaultValues?.fullStory ?? ""} placeholder="Full story" rows={10} />
<button type="submit" className="btn btn-primary">
{defaultValues ? "Save" : "Create"}
</button>
</form>
);
}
+78
View File
@@ -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<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<div className="card" style={{ marginTop: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
<label className="btn btn-primary" style={{ cursor: "pointer", fontSize: "0.88rem" }}>
{uploading ? "Uploading…" : "Upload image"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} style={{ display: "none" }} />
</label>
</div>
{loading ? (
<p className="muted">Loading…</p>
) : files.length === 0 ? (
<p className="muted">No images yet. Upload one above.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "0.75rem" }}>
{files.map((f) => (
<div key={f.name} style={{ border: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)", borderRadius: 10, overflow: "hidden", background: "var(--color-background)" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={f.url} alt={f.name} style={{ width: "100%", height: 120, objectFit: "cover", display: "block" }} />
<div style={{ padding: "0.4rem" }}>
<p style={{ fontSize: "0.72rem", margin: "0 0 0.3rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--color-text-muted)" }}>
{f.name}
</p>
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline" style={{ fontSize: "0.72rem", padding: "0.2rem 0.5rem", width: "100%" }}>
Copy URL
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+138
View File
@@ -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<MediaFile[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<button type="button" onClick={() => setOpen(true)} className="btn btn-outline" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}>
Browse
</button>
{current && (
<span style={{ fontSize: "0.78rem", color: "var(--color-text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 200 }}>
{current.split("/").pop()}
</span>
)}
</div>
{open && (
<div
style={{
position: "fixed", inset: 0, zIndex: 9999,
display: "flex", alignItems: "center", justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.5)",
}}
onClick={() => setOpen(false)}
>
<div
onClick={(e) => 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)",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.85rem 1rem", borderBottom: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)" }}>
<strong style={{ fontSize: "1rem" }}>Media Library</strong>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<label className="btn btn-primary" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem", cursor: "pointer" }}>
{uploading ? "Uploading…" : "Upload"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} style={{ display: "none" }} />
</label>
<button type="button" onClick={() => setOpen(false)} className="btn" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}>
Close
</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{loading ? (
<p className="muted" style={{ textAlign: "center", padding: "2rem" }}>Loading…</p>
) : files.length === 0 ? (
<p className="muted" style={{ textAlign: "center", padding: "2rem" }}>
No images yet. Upload one above.
</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: "0.65rem" }}>
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => { onSelect(f.url); setOpen(false); }}
style={{
border: `2px solid ${f.url === current ? "var(--color-primary)" : "transparent"}`,
borderRadius: 10, overflow: "hidden", cursor: "pointer",
background: "var(--color-background)", padding: 0,
display: "flex", flexDirection: "column",
transition: "border-color 0.15s",
}}
onMouseEnter={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "color-mix(in srgb, var(--color-primary) 40%, transparent)"; }}
onMouseLeave={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "transparent"; }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
style={{ width: "100%", height: 100, objectFit: "cover", display: "block" }}
/>
<span style={{ fontSize: "0.72rem", padding: "0.3rem 0.4rem", textAlign: "center", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}