Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

+9 -1
View File
@@ -9,7 +9,15 @@ import { usePathname } from "next/navigation";
* exactly; every other entry also matches its sub-routes (e.g. /admin/radio is
* active on /admin/radio/history) so the current section stays lit.
*/
export function AdminNavLink({ href, icon, children }: { href: string; icon?: ReactNode; children: ReactNode }) {
export function AdminNavLink({
href,
icon,
children,
}: {
href: string;
icon?: ReactNode;
children: ReactNode;
}) {
const pathname = usePathname() ?? "";
const active =
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
+26 -5
View File
@@ -22,11 +22,20 @@ export function ArticleForm({
<input
name="image"
value={image}
onChange={(e) => { setImage(e.target.value); setImageError(false); }}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder="Image URL"
style={{ flex: 1 }}
/>
<MediaPicker onSelect={(url) => { setImage(url); setImageError(false); }} current={image} />
<MediaPicker
onSelect={(url) => {
setImage(url);
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -38,11 +47,23 @@ export function ArticleForm({
/>
) : null}
{imageError ? (
<p className="muted" style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}>Image not found — enter a valid URL or browse.</p>
<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} />
<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>
+28 -13
View File
@@ -31,7 +31,9 @@ export function StatusCard({
return (
<div className="bg-[var(--color-surface)] border border-[var(--border-subtle)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--color-text-muted)]">{label}</span>
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--color-text-muted)]">
{label}
</span>
{icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/15 to-[var(--color-primary)]/5 grid place-items-center text-[var(--color-primary)] flex-none">
{icon}
@@ -39,7 +41,9 @@ export function StatusCard({
) : null}
</div>
<div className="text-2xl font-extrabold text-[var(--color-text)] leading-tight">{value}</div>
{hint ? <div className="text-[0.7rem] text-[var(--color-text-muted)] mt-1.5 font-medium">{hint}</div> : null}
{hint ? (
<div className="text-[0.7rem] text-[var(--color-text-muted)] mt-1.5 font-medium">{hint}</div>
) : null}
</div>
);
}
@@ -55,19 +59,27 @@ export function DiagnosticRow({
state: Exclude<State, "neutral">;
}) {
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
const dotColor = state === "ok" ? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]" :
state === "warn" ? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]" :
"bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor = state === "ok" ? "bg-green-600/20" :
state === "danger" ? "bg-red-500/22" :
"bg-[var(--color-primary)]/18";
const dotColor =
state === "ok"
? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]"
: state === "warn"
? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]"
: "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor =
state === "ok"
? "bg-green-600/20"
: state === "danger"
? "bg-red-500/22"
: "bg-[var(--color-primary)]/18";
return (
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--border-subtle)] first:border-t-0">
<span className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`} aria-hidden />
<span className="font-bold text-sm">{label}</span>
<span className="flex-1 text-[0.82rem] text-[var(--color-text-muted)]">{detail}</span>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}
>
{text}
</span>
</div>
@@ -95,7 +107,7 @@ export function OnlineUsersWidget({
const formatLastSeen = (date: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
@@ -110,7 +122,7 @@ export function OnlineUsersWidget({
{isLoading ? "..." : users.length} users
</span>
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
{users.length === 0 ? (
<div className="py-8 text-center bg-[var(--color-text-muted)]/5 rounded-lg">
@@ -136,10 +148,13 @@ export function OnlineUsersWidget({
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--color-text)] mb-1 truncate">
<a href={`/u/${user.username}`} className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline">
<a
href={`/u/${user.username}`}
className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline"
>
{user.username}
</a>
</div>
+45 -21
View File
@@ -25,30 +25,36 @@ export function AdminMediaGrid() {
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch { /* ignore */ }
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
}, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setProgress({ current: 0, total: files.length });
const fileArray = Array.from(files);
await Promise.all(fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress(prev => ({ ...prev, current: prev.current + 1 }));
}));
await Promise.all(
fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
);
await load();
setUploading(false);
@@ -63,10 +69,19 @@ export function AdminMediaGrid() {
return (
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
<strong>
{files.length} image{files.length !== 1 ? "s" : ""}
</strong>
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} multiple className="hidden" />
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
multiple
className="hidden"
/>
</label>
</div>
@@ -74,11 +89,13 @@ export function AdminMediaGrid() {
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</span>
<span>{progress.current} / {progress.total}</span>
<span>
{progress.current} / {progress.total}
</span>
</div>
<div className="h-2 w-full bg-[var(--color-primary)]/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
@@ -92,14 +109,21 @@ export function AdminMediaGrid() {
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => (
<div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
<div
key={f.name}
className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={validImageUrl(f.url)} alt={f.name} className="w-full h-[120px] object-cover block" />
<div className="p-2">
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</p>
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline text-xs p-1 w-full">
<button
type="button"
onClick={() => copyUrl(f.url)}
className="btn btn-outline text-xs p-1 w-full"
>
Copy URL
</button>
</div>
+84 -60
View File
@@ -12,13 +12,7 @@ function validImageUrl(url: string): string {
return url;
}
export function MediaPicker({
onSelect,
current,
}: {
onSelect: (url: string) => void;
current?: 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);
@@ -64,72 +58,102 @@ export function MediaPicker({
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" }}>
<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 }}>
<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
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
{open && (
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--color-surface)] text-[var(--color-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} className="hidden" multiple />
</label>
<button type="button" onClick={() => setOpen(false)} className="btn text-sm">
Close
</button>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--color-surface)] text-[var(--color-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
multiple
/>
</label>
<button type="button" onClick={() => setOpen(false)} className="btn text-sm">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => {
onSelect(f.url);
setOpen(false);
}}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--color-background)] p-0 flex flex-col transition-all duration-150"
style={{ borderColor: f.url === current ? "var(--color-primary)" : "transparent" }}
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={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => { onSelect(f.url); setOpen(false); }}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--color-background)] p-0 flex flex-col transition-all duration-150"
style={{ borderColor: f.url === current ? "var(--color-primary)" : "transparent" }}
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={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
);
+3 -8
View File
@@ -5,8 +5,7 @@ import { useEffect, useId, useRef, useState } from "react";
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
// is the documented sentinel for the free self-hosted/CDN build and silences
// the "register for an API key" notification.
const TINYMCE_SRC =
"https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
const TINYMCE_SRC = "https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
@@ -82,8 +81,7 @@ export function RichText({
menubar: false,
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
toolbar: "undo redo | bold italic underline | bullist numlist | link | removeformat | code",
branding: false,
promotion: false,
license_key: "gpl",
@@ -91,10 +89,7 @@ export function RichText({
content_style: "body{font-family:inherit;font-size:14px}",
// Mirror typed HTML back into the underlying textarea so the form
// submit (server action) always sees the current value.
setup: (editor: {
on: (ev: string, cb: () => void) => void;
getContent: () => string;
}) => {
setup: (editor: { on: (ev: string, cb: () => void) => void; getContent: () => string }) => {
const sync = () => {
if (textareaRef.current) textareaRef.current.value = editor.getContent();
};