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

+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>
)}
</>
);
}