refactor(admin): organize user actions and improve content editing UX
CI / check (push) Successful in 1m32s
CI / deploy (push) Successful in 1m11s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 18:56:56 +02:00
1 parent e17346ab9f
commit c6b919c01d
10 files changed
+688 -458

No files matched your search

@@ -1,3 +1,4 @@
"use client";
import {
ArrowLeftRight,
DoorOpen,
@@ -8,6 +9,7 @@ import {
Wifi,
WifiOff,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
import Link from "@/components/link";
@@ -15,6 +17,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getAvatarUrl } from "@/lib/imager";
import { userDetailHref, userListHref } from "../user-list-navigation";
interface UserHeroProps {
user: {
@@ -55,11 +58,14 @@ export function UserHero({
canEdit,
watching,
}: UserHeroProps) {
const params = useSearchParams();
const list = params.get("list") ?? "";
const listHref = userListHref(list);
return (
<>
<AdminBreadcrumb
items={[
{ label: "Users", href: "/admin/users" },
{ label: "Users", href: listHref },
{ label: user.username },
{ label: mode === "edit" ? "Edit" : "View" },
]}
@@ -99,14 +105,14 @@ export function UserHero({
{/* Mode switch buttons */}
{mode === "show" && canEdit && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={`/admin/users/edit/${user.id}`}>
<Link href={userDetailHref(user.id, "edit", list)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
</Link>
</Button>
)}
{mode === "edit" && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={`/admin/users/show/${user.id}`}>
<Link href={userDetailHref(user.id, "show", list)}>
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
</Link>
</Button>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { userDetailHref, userListHref } from "./user-list-navigation";
it("preserves search filters and pagination through detail and edit links", () => {
const query = "search=Mario%20Rossi&page=3&filter_online=1&perPage=50";
for (const mode of ["show", "edit"] as const) {
const link = new URL(
userDetailHref(9, mode, query),
"https://example.test",
);
expect(userListHref(link.searchParams.get("list") ?? "")).toBe(
"/admin/users?search=Mario+Rossi&page=3&filter_online=1&perPage=50",
);
}
});
it("always returns a local users-list URL", () => {
expect(userListHref("")).toBe("/admin/users");
expect(userListHref("https://other.example")).toMatch(/^\/admin\/users\?/);
});
@@ -0,0 +1,11 @@
export function userListHref(query: string): string {
const search = new URLSearchParams(query).toString();
return search ? `/admin/users?${search}` : "/admin/users";
}
export function userDetailHref(
id: number,
mode: "show" | "edit",
query: string,
): string {
return `/admin/users/${mode}/${id}?list=${encodeURIComponent(query)}`;
}
File diff suppressed because it is too large. Load diff
+42 -5
View File
@@ -1,8 +1,10 @@
"use client";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text";
@@ -30,10 +32,13 @@ export function ArticleForm({
const [imageError, setImageError] = useState(false);
const [status, setStatus] = useState(defaultValues?.status ?? "published");
const formRef = useRef<HTMLFormElement>(null);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
return (
<form
ref={formRef}
action={action}
className="admin-card"
style={{ display: "grid", gap: "1rem" }}
@@ -76,6 +81,7 @@ export function ArticleForm({
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
value={image}
onChange={(e) => {
@@ -83,7 +89,7 @@ export function ArticleForm({
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered flex-1"
className="input input-bordered min-w-0 flex-1"
/>
<MediaPicker
onSelect={(url) => {
@@ -168,9 +174,40 @@ export function ArticleForm({
) : null}
</div>
<button type="submit" className="btn btn-primary justify-self-start">
{t("save")}
</button>
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!formRef.current) return;
const values = new FormData(formRef.current);
setPreview({
title: String(values.get("title") ?? ""),
image: String(values.get("image") ?? ""),
summary: String(values.get("shortStory") ?? ""),
body: String(values.get("fullStory") ?? ""),
});
}}
>
Preview
</button>
<ArticleSaveButton label={t("save")} />
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
</form>
);
}
function ArticleSaveButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
className="btn btn-primary"
disabled={pending}
aria-busy={pending}
>
{pending ? "Saving…" : label}
</button>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function escapeHtml(value: string) {
return value.replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
] ?? c,
);
}
export interface ArticlePreviewData {
title: string;
image: string;
summary: string;
body: string;
}
export function ArticlePreview({
data,
onClose,
}: {
data: ArticlePreviewData | null;
onClose: () => void;
}) {
const document = data
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
: "";
return (
<Dialog
open={!!data}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
<DialogHeader>
<DialogTitle>Article preview</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
Content preview. The published page applies the site theme.
</p>
<iframe
title="Article content preview"
sandbox=""
srcDoc={document}
className="min-h-0 flex-1 w-full bg-background rounded border"
/>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadMediaAndReturn } from "@/actions/admin-media";
type MediaFile = { name: string; url: string };
export function useMediaLibrary(
open: boolean,
setOpen: (open: boolean) => void,
onSelect: (url: string) => void,
) {
const [files, setFiles] = useState<MediaFile[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/media");
if (!res.ok) throw Error("Could not load media library");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
setError("Could not load media library. Please retry.");
}
setLoading(false);
}, []);
useEffect(() => {
if (open) load();
}, [open, load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const chosen = Array.from(e.target.files ?? []);
if (!chosen.length || uploading) return;
setUploading(true);
setError("");
const urls: string[] = [];
try {
for (const file of chosen) {
const fd = new FormData();
fd.set("file", file);
const url = await uploadMediaAndReturn(fd);
if (!url)
throw Error(
`Could not upload ${file.name}. Check file type and size.`,
);
urls.push(url);
}
await load();
onSelect(urls[urls.length - 1]);
setOpen(false);
} catch (err) {
if (urls.length) await load();
setError(
err instanceof Error ? err.message : "Upload failed. Please retry.",
);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return { files, loading, uploading, fileRef, error, load, handleUpload };
}