Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
@@ -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}/`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user