feat: comprehensive CMS improvements
CI / runtime-diagnostics (push) Skipped
CI / release (push) Skipped
CI / check (push) Failing after 0s
CI / deploy (push) Skipped

- Fix DOMPurify SSR crash (use isomorphic-dompurify)
- Fix SanitizedHtml to sanitize by default
- Add auth guards to studio/catalog maintenance pages
- Add update/edit to vouchers CRUD
- Add update/edit to rare-values CRUD
- Add approve workflow to applications page
- Add edit form to guilds detail page
- Add SEO metadata to all public pages (21 pages)
- Fix mobile nav accessibility (focus trap, aria attributes)
- Fix missing labels and table accessibility
- Add dynamic imports for heavy client components (6 components)
- Fix silent error swallowing (40+ locations)
- Add content scheduling for articles (publishAt, status)
- Wire up 12 missing webhook notification triggers
- Add global search to admin panel
- Add bulk actions to admin users table
- Fix JSON formatting and a11y issues
This commit is contained in:
openhands committed 2026-09-03 16:00:32 +02:00
1 parent b810b16672
commit 30c95b1a5c
78 files changed
+2252 -134

No files matched your search

+2
View File
@@ -2,6 +2,7 @@
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { SearchDialog } from "@/components/admin/search-dialog";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
@@ -29,6 +30,7 @@ export function AdminTopbar({
</span>
</div>
<div className="flex items-center gap-3">
<SearchDialog />
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
approveApplication,
dismissApplication,
} from "@/actions/admin-applications";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
export function ApplicationActions({ id }: { id: string }) {
const t = useTranslations("pages.admin.applications");
const router = useRouter();
const [pending, startTransition] = useTransition();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleApprove() {
const ok = await confirm({
title: t("approve"),
description: t("confirmApprove"),
confirmLabel: t("approve"),
variant: "default",
});
if (!ok) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
await approveApplication(fd);
router.refresh();
});
}
async function handleReject() {
const ok = await confirm({
title: t("reject"),
description: t("confirmReject"),
confirmLabel: t("reject"),
variant: "danger",
});
if (!ok) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
await dismissApplication(fd);
router.refresh();
});
}
return (
<div className="flex gap-2">
{confirmDialog}
<Button
type="button"
variant="default"
size="sm"
disabled={pending}
onClick={handleApprove}
>
{t("approve")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={handleReject}
>
{t("reject")}
</Button>
</div>
);
}
+36
View File
@@ -18,6 +18,8 @@ export function ArticleForm({
image?: string;
shortStory?: string;
fullStory?: string;
status?: string;
publishAt?: string;
};
}) {
const t = useTranslations("pages.admin.articles.form");
@@ -26,6 +28,7 @@ export function ArticleForm({
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const [status, setStatus] = useState(defaultValues?.status ?? "published");
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -132,6 +135,39 @@ export function ArticleForm({
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("status")}
</span>
<select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="input input-bordered mt-1 w-full"
>
<option value="draft">{t("statusDraft")}</option>
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
</label>
{status === "scheduled" ? (
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
</span>
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
required
className="input input-bordered mt-1 w-full"
/>
</label>
) : null}
</div>
<button type="submit" className="btn btn-primary justify-self-start">
{t("save")}
</button>
@@ -24,7 +24,9 @@ export function BreadcrumbBar() {
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
}
})
.catch(() => {});
.catch((error) =>
console.error("[Breadcrumb] Failed to fetch ancestors:", error),
);
return () => ac.abort();
}, [selectedPageId, dispatch, catQs]);
@@ -667,7 +667,9 @@ export function SortableTree({
ids: pages.map((p) => p.id),
});
})
.catch(() => {});
.catch((error) =>
console.error("[SortableTree] Failed to load children:", error),
);
}
}
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
@@ -81,7 +81,7 @@ export function QuickAddFurni({
)
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.catch((error) => console.error("[QuickAdd] Furni search failed:", error))
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
+1 -1
View File
@@ -101,7 +101,7 @@ export function AdminMediaGrid() {
setCopied(url);
setTimeout(() => setCopied(null), 1500);
})
.catch(() => {});
.catch((error) => console.error("[Media] Clipboard copy failed:", error));
}
async function remove(name: string) {
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce";
type SearchResult = {
type: string;
id: number | string;
title: string;
subtitle: string;
url: string;
};
type SearchResponse = {
ok: boolean;
results: SearchResult[];
};
export function SearchDialog() {
const t = useTranslations("pages.admin.search");
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 250);
const abortRef = useRef<AbortController | null>(null);
const groupLabels: Record<string, string> = {
users: t("users"),
articles: t("articles"),
rooms: t("rooms"),
guilds: t("guilds"),
shop: t("shop"),
rareValues: t("rareValues"),
};
const fetchResults = useCallback(async (q: string) => {
abortRef.current?.abort();
if (q.length < 2) {
setResults([]);
setLoading(false);
return;
}
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
const data: SearchResponse = await res.json();
if (data.ok) setResults(data.results);
} catch {}
setLoading(false);
}, []);
useEffect(() => {
fetchResults(debouncedQuery);
}, [debouncedQuery, fetchResults]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
function handleSelect(url: string) {
setOpen(false);
setQuery("");
setResults([]);
router.push(url);
}
const grouped = groupResults(results);
return (
<Dialog open={open} onOpenChange={setOpen}>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
>
<SearchIcon size={14} />
<span className="hidden sm:inline">{t("placeholder")}</span>
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
⌘K
</kbd>
</button>
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
shouldFilter={false}
>
<CommandInput
placeholder={t("placeholder")}
value={query}
onValueChange={setQuery}
className="h-12"
/>
<CommandList className="max-h-[400px]">
{loading && query.length >= 2 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
…
</div>
) : null}
<CommandEmpty>{t("noResults")}</CommandEmpty>
{Object.entries(grouped).map(([group, items]) => (
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
{items.map((item) => (
<CommandItem
key={`${item.type}-${item.id}`}
value={`${item.type}-${item.id}`}
onSelect={() => handleSelect(item.url)}
className="flex flex-col items-start gap-0.5 py-2"
>
<span className="text-sm font-medium">{item.title}</span>
{item.subtitle ? (
<span className="text-xs text-muted-foreground truncate w-full">
{item.subtitle}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function groupResults(results: SearchResult[]) {
const grouped: Record<string, SearchResult[]> = {};
for (const r of results) {
if (!grouped[r.type]) grouped[r.type] = [];
grouped[r.type].push(r);
}
return grouped;
}
+65 -2
View File
@@ -2,7 +2,13 @@
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { type ReactNode, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { mobileMenuVariants } from "@/lib/motion";
interface MobileNavProps {
@@ -20,6 +26,57 @@ export function MobileNav({
}: MobileNavProps) {
const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const MENU_ID = "mobile-nav-menu";
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
detailsRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
}
},
[open],
);
useEffect(() => {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [handleEscape]);
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
if (!menu) return;
const focusableSelector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function handleTab(e: KeyboardEvent) {
if (e.key !== "Tab" || !menu) return;
const focusable = Array.from(
menu.querySelectorAll<HTMLElement>(focusableSelector),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
menu.addEventListener("keydown", handleTab);
return () => menu.removeEventListener("keydown", handleTab);
}, [open]);
return (
<div ref={detailsRef} className="group relative md:hidden">
@@ -33,6 +90,8 @@ export function MobileNav({
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
aria-label={open ? closeLabel : menuLabel}
aria-expanded={open}
aria-haspopup="true"
aria-controls={MENU_ID}
>
<motion.svg
className="w-6 h-6"
@@ -63,6 +122,9 @@ export function MobileNav({
<AnimatePresence>
{open && (
<motion.div
id={MENU_ID}
role="menu"
aria-label={brandLabel || "Navigation menu"}
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
style={{ backgroundColor: "transparent" }}
variants={mobileMenuVariants}
@@ -71,8 +133,9 @@ export function MobileNav({
exit="exit"
>
<div
ref={menuRef}
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
+1 -1
View File
@@ -171,7 +171,7 @@ export default function RadioPlayer() {
.then((data) => {
if (data !== null) setConfig(parseConfig(data));
})
.catch(() => {});
.catch((error) => console.error("[Radio] Config fetch failed:", error));
}, []);
// Keep the <audio> volume in sync with the slider.
+5 -1
View File
@@ -15,7 +15,11 @@ export function PwaRegister() {
}
});
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
navigator.serviceWorker
.register(`/sw.js?${SW_VERSION}`)
.catch((error) =>
console.error("[PWA] Service worker registration failed:", error),
);
}, []);
return null;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { CSSProperties } from "react";
import { sanitize } from "@/lib/sanitize";
interface SanitizedHtmlProps {
html: string;
@@ -11,7 +12,7 @@ export function SanitizedHtml({ html, className, style }: SanitizedHtmlProps) {
<div
className={className}
style={style}
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{ __html: sanitize(html) }}
/>
);
}