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

+14 -1
View File
@@ -1,8 +1,8 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +16,19 @@ import {
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const RevenueChart = dynamic(
() =>
import("@/components/admin/revenue-chart").then((m) => ({
default: m.RevenueChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
type EconomyData = {
totalCredits: number;
totalPixels: number;
+27 -2
View File
@@ -7,16 +7,41 @@ import {
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const DashboardChart = dynamic(
() =>
import("@/components/admin/dashboard-chart").then((m) => ({
default: m.DashboardChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
const PeakHoursHeatmap = dynamic(
() =>
import("@/components/admin/peak-hours-heatmap").then((m) => ({
default: m.PeakHoursHeatmap,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
type AnalyticsData = {
totalUsers: number;
onlineUsers: number;
+8 -11
View File
@@ -1,13 +1,14 @@
import { desc, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { ApplicationActions } from "@/components/admin/application-actions";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteStaffApplications } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
type ApplicationRow = {
id: bigint;
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
.from(WebsiteStaffApplications)
.orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch {
} catch (error) {
logServerError("applications.query_failed", error);
applications = [];
}
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
.from(User)
.where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
} catch (error) {
logServerError("applications.user_lookup_failed", error);
}
}
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<Button type="submit" variant="destructive">
{t("dismiss")}
</Button>
</form>
<ApplicationActions id={String(a.id)} />
</article>
);
})}
+8 -1
View File
@@ -1,5 +1,12 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function CatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function CatalogMaintenancePage() {
return <CatalogMaintenancePanel />;
}
+141 -1
View File
@@ -1,7 +1,7 @@
import { asc, count, eq, inArray } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { disbandGuild } from "@/actions/admin-guilds";
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
dateCreated: Guilds.dateCreated,
state: Guilds.state,
forum: Guilds.forum,
readForum: Guilds.readForum,
postMessages: Guilds.postMessages,
postThreads: Guilds.postThreads,
modForum: Guilds.modForum,
})
.from(Guilds)
.where(eq(Guilds.id, id))
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
</p>
) : null}
{canEdit ? (
<section>
<h2 className="admin-section-title">{t("edit")}</h2>
<form action={updateGuild} className="space-y-3">
<input type="hidden" name="id" value={guild.id} />
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
<div>
<label htmlFor="guild-name" className="text-xs muted">
{t("formName")}
</label>
<input
id="guild-name"
type="text"
name="name"
maxLength={50}
defaultValue={guild.name}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-description" className="text-xs muted">
{t("formDescription")}
</label>
<input
id="guild-description"
type="text"
name="description"
maxLength={250}
defaultValue={guild.description}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-state" className="text-xs muted">
{t("formState")}
</label>
<select
id="guild-state"
name="state"
defaultValue={guild.state}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Normal</option>
<option value="1">1 — Locked</option>
<option value="2">2 — Exclusive</option>
</select>
</div>
<div>
<label htmlFor="guild-forum" className="text-xs muted">
{t("formForum")}
</label>
<select
id="guild-forum"
name="forum"
defaultValue={guild.forum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Off</option>
<option value="1">1 — On</option>
</select>
</div>
<div>
<label htmlFor="guild-readForum" className="text-xs muted">
{t("formReadForum")}
</label>
<select
id="guild-readForum"
name="readForum"
defaultValue={guild.readForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postMessages" className="text-xs muted">
{t("formPostMessages")}
</label>
<select
id="guild-postMessages"
name="postMessages"
defaultValue={guild.postMessages}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postThreads" className="text-xs muted">
{t("formPostThreads")}
</label>
<select
id="guild-postThreads"
name="postThreads"
defaultValue={guild.postThreads}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-modForum" className="text-xs muted">
{t("formModForum")}
</label>
<select
id="guild-modForum"
name="modForum"
defaultValue={guild.modForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="ADMINS">ADMINS</option>
<option value="OWNER">OWNER</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
</div>
<Button type="submit" variant="ghost" size="sm">
{t("save")}
</Button>
</form>
</section>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
<div className="admin-card">
<div className="text-xs muted">{t("colOwner")}</div>
+14 -1
View File
@@ -1,7 +1,20 @@
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { AdminMediaGrid } from "@/components/admin/media-grid";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const AdminMediaGrid = dynamic(
() =>
import("@/components/admin/media-grid").then((m) => ({
default: m.AdminMediaGrid,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
export default async function AdminMediaPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
@@ -104,6 +104,7 @@ export function ModActionsClient() {
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Enter user ID"
aria-label="User ID"
/>
</div>
@@ -144,6 +145,7 @@ export function ModActionsClient() {
onChange={(e) => setMuteDuration(e.target.value)}
className="w-24"
placeholder="Min"
aria-label="Mute duration in minutes"
/>
<Button
variant="outline"
@@ -215,6 +217,7 @@ export function ModActionsClient() {
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
placeholder="Enter room ID"
aria-label="Room ID"
/>
</div>
<Button
+147 -32
View File
@@ -6,6 +6,8 @@ import {
createValue,
deleteCategory,
deleteValue,
updateCategory,
updateValue,
} from "@/actions/admin-rare-values";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
{t("itemCount", { count: rows.length })}
</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
<div className="flex gap-2 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
{t("editCategory")}
</summary>
<form
action={updateCategory}
className="mt-2 flex gap-2 flex-wrap"
>
<input type="hidden" name="id" value={catId} />
<input
name="name"
defaultValue={cat.name}
placeholder={t("categoryForm.namePlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input
name="badge"
defaultValue={cat.badge}
placeholder={t("badgePlaceholder")}
required
/>
<input
name="priority"
type="number"
min={1}
defaultValue={cat.priority}
placeholder={t("priorityPlaceholder")}
className="w-[110px]"
/>
<Button type="submit" variant="default">
{t("categoryForm.save")}
</Button>
</form>
</details>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
</div>
</div>
<table style={{ marginTop: "0.75rem" }}>
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={String(v.id)}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<form action={deleteValue}>
<input type="hidden" name="id" value={String(v.id)} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</td>
</tr>
))}
{rows.map((v) => {
const vId = String(v.id);
return (
<tr key={vId}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<div className="flex gap-1 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
{t("editValue")}
</summary>
<form
action={updateValue}
className="mt-2 flex gap-1 flex-wrap items-end"
>
<input type="hidden" name="id" value={vId} />
<input
type="hidden"
name="categoryId"
value={catId}
/>
<input
name="name"
defaultValue={v.name}
placeholder={t(
"itemForm.itemNamePlaceholder",
)}
required
className="min-w-[120px]"
/>
<input
name="itemId"
type="number"
min={1}
defaultValue={v.itemId ?? ""}
placeholder={t("itemForm.itemIdPlaceholder")}
className="w-[90px]"
/>
<input
name="furnitureIcon"
defaultValue={v.furnitureIcon}
placeholder={t("itemForm.iconPlaceholder")}
required
className="min-w-[120px]"
/>
<input
name="creditValue"
defaultValue={v.creditValue ?? ""}
placeholder={t("itemForm.creditsPlaceholder")}
className="w-[110px]"
/>
<input
name="currencyValue"
defaultValue={v.currencyValue ?? ""}
placeholder={t(
"itemForm.currencyValuePlaceholder",
)}
className="w-[110px]"
/>
<select
name="currencyType"
defaultValue={v.currencyType}
>
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<Button
type="submit"
variant="default"
className="text-xs"
>
{t("itemForm.save")}
</Button>
</form>
</details>
<form action={deleteValue}>
<input type="hidden" name="id" value={vId} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{rows.length === 0 ? (
+8 -1
View File
@@ -1,6 +1,13 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function StudioCatalogMaintenancePage() {
return (
<div className="min-h-0">
<CatalogMaintenancePanel />
+131 -9
View File
@@ -2,7 +2,11 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import {
createVoucher,
deleteVoucher,
updateVoucher,
} from "@/actions/admin-vouchers";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon";
@@ -33,10 +37,15 @@ export function VouchersClient({
const t = useTranslations("pages.admin.vouchers");
const { run, isPending } = useServerAction();
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
const [code, setCode] = useState("");
const [amount, setAmount] = useState("");
const [maxUses, setMaxUses] = useState("1");
const [expiresAt, setExpiresAt] = useState("");
const [editCode, setEditCode] = useState("");
const [editAmount, setEditAmount] = useState("");
const [editMaxUses, setEditMaxUses] = useState("1");
const [editExpiresAt, setEditExpiresAt] = useState("");
function handleCreate(e: React.FormEvent) {
e.preventDefault();
@@ -72,6 +81,36 @@ export function VouchersClient({
});
}
function handleStartEdit(v: VoucherRow) {
setEditingVoucher(v);
setEditCode(v.code);
setEditAmount(String(v.amount));
setEditMaxUses(String(v.maxUses));
setEditExpiresAt(
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
);
}
function handleUpdate(e: React.FormEvent) {
e.preventDefault();
if (!editingVoucher || !canEdit) return;
run(
() =>
updateVoucher({
id: editingVoucher.id,
code: editCode,
amount: Number(editAmount),
maxUses: Number(editMaxUses),
expiresAt: editExpiresAt || undefined,
}),
{
successMessage: t("updated"),
errorMessage: t("updateError"),
onSuccess: () => setEditingVoucher(null),
},
);
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
@@ -158,6 +197,79 @@ export function VouchersClient({
</form>
)}
{canEdit && editingVoucher && (
<form onSubmit={handleUpdate} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>
{t("edit")} — {editingVoucher.code}
</h3>
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
value={editAmount}
onChange={(e) => setEditAmount(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
value={editMaxUses}
onChange={(e) => setEditMaxUses(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
type="datetime-local"
disabled={isPending}
/>
</label>
</div>
<div className="flex items-center gap-2">
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? t("form.saving") : t("form.save")}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setEditingVoucher(null)}
>
{t("form.cancel")}
</Button>
</div>
</form>
)}
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
@@ -216,14 +328,24 @@ export function VouchersClient({
</td>
{canEdit && (
<td>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() => handleStartEdit(v)}
>
{t("edit")}
</Button>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
</div>
</td>
)}
</tr>