feat(admin): reorganize housekeeping and strengthen shared workflows

This commit is contained in:
Simo committed 2026-09-06 17:17:30 +02:00
1 parent 35f66d879f
commit a070004e7b
35 files changed
+1667 -462

No files matched your search

+40 -41
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { ErrorStore } from "@/lib/error-monitor"; import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -11,6 +12,7 @@ export default async function CmsErrorsPage({
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin"); redirect("/admin");
const t = await getTranslations("pages.admin.cmsErrors");
const params = await searchParams; const params = await searchParams;
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase(); const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
let result: Awaited<ReturnType<ErrorStore["read"]>>; let result: Awaited<ReturnType<ErrorStore["read"]>>;
@@ -19,14 +21,15 @@ export default async function CmsErrorsPage({
} catch { } catch {
return ( return (
<div className="admin-card" role="alert"> <div className="admin-card" role="alert">
Error storage could not be read. Check the CMS storage volume and {t("storageError")}
permissions.
</div> </div>
); );
} }
const filtered = result.records.filter( const filtered = result.records.filter(
(r) => (r) =>
(!params.source || r.source === params.source) && (!params.source || r.source === params.source) &&
(params.status !== "open" || !r.resolved) &&
(params.status !== "resolved" || r.resolved) &&
(!query || JSON.stringify(r).toLowerCase().includes(query)), (!query || JSON.stringify(r).toLowerCase().includes(query)),
); );
const groups = new Map<string, typeof filtered>(); const groups = new Map<string, typeof filtered>();
@@ -43,62 +46,62 @@ export default async function CmsErrorsPage({
), ),
); );
const pageHref = (next: number) => const pageHref = (next: number) =>
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`; `?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h1 className="text-2xl font-semibold">CMS error center</h1> <h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("subtitle")}</p>
Server and browser errors from the last 7 days. Browser reports are
unverified client observations.
</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link href="/admin/devops" className="btn btn-outline"> <Link href="/admin/devops" className="btn btn-outline">
DevOps DevOps
</Link> </Link>
<Link href="/admin/devops/errors" className="btn btn-outline"> <Link href="/admin/devops/errors" className="btn btn-outline">
Emulator errors {t("emulator")}
</Link> </Link>
<Link href="/admin/devops/cms-errors" className="btn btn-outline"> <Link href="/admin/devops/cms-errors" className="btn btn-outline">
Refresh / clear filters {t("refresh")}
</Link> </Link>
</div> </div>
<form className="admin-card flex flex-wrap gap-3"> <form className="admin-card flex flex-wrap gap-3">
<input <input
aria-label="Search errors" aria-label={t("search")}
name="q" name="q"
defaultValue={params.q} defaultValue={params.q}
placeholder="Reference, digest, message, route or release" placeholder={t("searchHint")}
className="input min-w-0 flex-1" className="input min-w-0 flex-1"
/> />
<select <select
name="source" name="source"
aria-label="Error source" aria-label={t("source")}
defaultValue={params.source ?? ""} defaultValue={params.source ?? ""}
className="input" className="input"
> >
<option value="">All sources</option> <option value="">{t("allSources")}</option>
<option value="server">Server</option> <option value="server">Server</option>
<option value="browser">Browser</option> <option value="browser">Browser</option>
</select> </select>
<select
name="status"
aria-label={t("status")}
defaultValue={params.status ?? ""}
className="input"
>
<option value="">{t("allStatuses")}</option>
<option value="open">{t("open")}</option>
<option value="resolved">{t("resolved")}</option>
</select>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Search {t("search")}
</button> </button>
</form> </form>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{filtered.length} occurrences · {groups.size} groups.{" "} {t("counts", { count: filtered.length, groups: groups.size })}{" "}
{result.truncated {result.truncated ? t("truncated") : ""} {t("retention")}
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
: ""}{" "}
Storage is limited to 10 MB per day; excess events remain in server logs
where available.
</p> </p>
{groups.size === 0 ? ( {groups.size === 0 ? (
<div className="admin-card"> <div className="admin-card">{t("empty")}</div>
No captured errors match these filters. Monitoring starts with this
deployment; older console logs are not imported.
</div>
) : null} ) : null}
{[...groups.entries()] {[...groups.entries()]
.slice((page - 1) * 25, page * 25) .slice((page - 1) * 25, page * 25)
@@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
<details key={fingerprint} className="admin-card overflow-hidden"> <details key={fingerprint} className="admin-card overflow-hidden">
<summary className="cursor-pointer"> <summary className="cursor-pointer">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "} {t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "}
{events.length} occurrences · {r.at} {t("occurrences", { count: events.length })} · {r.at}
</span> </span>
<p className="font-semibold break-words">{r.event}</p> <p className="font-semibold break-words">{r.event}</p>
<p className="text-sm break-words">{r.message}</p> <p className="text-sm break-words">{r.message}</p>
</summary> </summary>
<div className="mt-4 space-y-3 text-sm"> <div className="mt-4 space-y-3 text-sm">
<p> <p>
Reference: <code className="break-all">{r.id}</code> {t("reference")}: <code className="break-all">{r.id}</code>
</p> </p>
<p> <p>
Release: <code className="break-all">{r.release}</code> {t("release")}: <code className="break-all">{r.release}</code>
</p> </p>
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3"> <pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
{JSON.stringify(r.context, null, 2)} {JSON.stringify(r.context, null, 2)}
</pre> </pre>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3"> <pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
{r.stack || {r.stack || t("noStack")}
"No stack supplied. Use the reference or digest to correlate server logs."}
</pre> </pre>
{!r.resolved && {!r.resolved &&
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? ( canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
@@ -137,16 +139,16 @@ export default async function CmsErrorsPage({
value={r.fingerprint} value={r.fingerprint}
/> />
<button type="submit" className="btn btn-outline"> <button type="submit" className="btn btn-outline">
Mark resolved {t("resolve")}
</button> </button>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
A new occurrence reopens the group automatically. {t("reopenHint")}
</p> </p>
</form> </form>
) : null} ) : null}
<details> <details>
<summary className="cursor-pointer"> <summary className="cursor-pointer">
Occurrence references {t("references")}
</summary> </summary>
<ul> <ul>
{events.map((e) => ( {events.map((e) => (
@@ -156,23 +158,20 @@ export default async function CmsErrorsPage({
))} ))}
</ul> </ul>
</details> </details>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("stackHint")}</p>
Browser stack traces may reference compiled chunks. This view
does not infer a root cause or include request bodies.
</p>
</div> </div>
</details> </details>
); );
})} })}
<nav aria-label="Error pages" className="flex gap-3"> <nav aria-label={t("pages")} className="flex gap-3">
{page > 1 ? ( {page > 1 ? (
<Link href={pageHref(page - 1)} className="btn btn-outline"> <Link href={pageHref(page - 1)} className="btn btn-outline">
Previous {t("previous")}
</Link> </Link>
) : null} ) : null}
{page * 25 < groups.size ? ( {page * 25 < groups.size ? (
<Link href={pageHref(page + 1)} className="btn btn-outline"> <Link href={pageHref(page + 1)} className="btn btn-outline">
Next {t("next")}
</Link> </Link>
) : null} ) : null}
</nav> </nav>
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function DevOpsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline"> <a href="/admin/devops/cms-errors" className="btn btn-outline">
CMS error center {t("cmsErrors")}
</a> </a>
{/* Status cards */} {/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
+6 -28
View File
@@ -11,14 +11,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link"; import Link from "@/components/link";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav"; import { loadAdminNavigation } from "@/lib/admin/navigation";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security"; import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be // Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
@@ -31,6 +26,7 @@ export default async function AdminLayout({
children: ReactNode; children: ReactNode;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
const navigation = await loadAdminNavigation();
const csrfToken = await readCsrfCookieToken(); const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) { if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await db const u = await db
@@ -52,7 +48,7 @@ export default async function AdminLayout({
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full" className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
style={{ backgroundColor: "var(--admin-canvas)" }} style={{ backgroundColor: "var(--admin-canvas)" }}
> >
<AdminTopbar staff={staff} /> <AdminTopbar staff={staff} navigation={navigation} />
<section className="admin-page flex-1"> <section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome> <AdminHubChrome>{children}</AdminHubChrome>
</section> </section>
@@ -69,27 +65,8 @@ async function Sidebar({
}) { }) {
const t = await getTranslations("pages.admin.nav"); const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase(); const initial = staff.username.charAt(0).toUpperCase();
const { permissions } = await getAdminContext(); const { allowedPermissions, isSuperAdmin, navConfig } =
const isSuperAdmin = permissions.isSuperAdmin; await loadAdminNavigation();
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
staff.rank,
);
// Super-admins see everything. Everyone else sees items their ACL grants.
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
// migration gaps), still show *.view nav entries so categories do not vanish.
const allowedPermissions = isSuperAdmin
? []
: collectNavPermissionSlugs().filter((slug) => {
if (canAccess(permissions, slug, staff.rank)) return true;
if (!hasDashboard) return false;
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
});
const navConfig = parseAdminNavConfig(
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
);
return ( return (
<aside <aside
@@ -118,6 +95,7 @@ async function Sidebar({
</div> </div>
<AdminSidebarNav <AdminSidebarNav
userId={staff.id}
allowedPermissions={allowedPermissions} allowedPermissions={allowedPermissions}
isSuperAdmin={isSuperAdmin} isSuperAdmin={isSuperAdmin}
navConfig={navConfig} navConfig={navConfig}
+2
View File
@@ -8,6 +8,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AdminWorkQueue } from "@/components/admin/admin-work-queue";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link"; import Link from "@/components/link";
import { unixNow } from "@/lib/bans"; import { unixNow } from "@/lib/bans";
@@ -126,6 +127,7 @@ export default async function AdminDashboard() {
/> />
</div> </div>
<AdminWorkQueue />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5"> <section className="admin-card p-5">
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title"> <h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { CatalogGitConfiguration } from "./catalog-git-configuration"; import { CatalogGitConfiguration } from "./catalog-git-configuration";
@@ -25,7 +26,10 @@ export function CatalogExportClient({
const [status, setStatus] = useState<ExportStatus | null>(null); const [status, setStatus] = useState<ExportStatus | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try { try {
const response = await adminFetch("/api/admin/catalog-export", { const response = await adminFetch("/api/admin/catalog-export", {
cache: "no-store", cache: "no-store",
@@ -35,15 +39,11 @@ export function CatalogExportClient({
setError(""); setError("");
} catch { } catch {
setError("Unable to load export status"); setError("Unable to load export status");
} finally {
refreshing.current = false;
} }
}, []); }, []);
useEffect(() => { useVisiblePolling(refresh, 15000);
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15_000);
return () => clearInterval(timer);
}, [refresh]);
async function retry() { async function retry() {
setBusy(true); setBusy(true);
try { try {
+6 -4
View File
@@ -27,8 +27,10 @@ export default async function UserShowPage({
const t = await getTranslations("pages.admin.users.detail"); const t = await getTranslations("pages.admin.users.detail");
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank); const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id); const data = await loadUserById(id);
const sanctions = await loadUserSanctions(data.user.id); const [sanctions, watched] = await Promise.all([
const watched = await getWatchedUserIds(session.user.id); loadUserSanctions(data.user.id),
getWatchedUserIds(session.user.id),
]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -54,7 +56,7 @@ export default async function UserShowPage({
} }
> >
<Tabs defaultValue="info" className="space-y-4 min-w-0"> <Tabs defaultValue="info" className="space-y-4 min-w-0">
<TabsList> <TabsList className="h-auto flex-wrap justify-start">
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger> <TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
<TabsTrigger value="badges"> <TabsTrigger value="badges">
{t("tabBadges", { count: data.user.badges.length })} {t("tabBadges", { count: data.user.badges.length })}
@@ -75,7 +77,7 @@ export default async function UserShowPage({
<TabsContent value="badges"> <TabsContent value="badges">
<div className="rounded-lg border bg-card p-4"> <div className="rounded-lg border bg-card p-4">
<h3 className="text-sm font-semibold mb-3"> <h3 className="text-sm font-semibold mb-3">
Badges ({data.user.badges.length}) {t("tabBadges", { count: data.user.badges.length })}
</h3> </h3>
<UserBadgesReadonly badges={data.user.badges} /> <UserBadgesReadonly badges={data.user.badges} />
</div> </div>
+115 -86
View File
@@ -1,5 +1,6 @@
import { eq, like, or } from "drizzle-orm"; import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
import { apiOk } from "@/lib/api"; import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { import {
@@ -11,7 +12,7 @@ import {
WebsiteRareValues, WebsiteRareValues,
WebsiteShopArticles, WebsiteShopArticles,
} from "@/lib/db"; } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { canAccess, PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = { export type AdminSearchResult = {
@@ -53,102 +54,130 @@ export const GET = withAdmin(
return apiOk({ results: [] }); return apiOk({ results: [] });
} }
const canSearch = (href: string) => {
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
(item) => item.href === href,
);
return (
!!item &&
navItemIsAllowed(item, {
isSuperAdmin: context.permissions.isSuperAdmin,
has: (slug) =>
canAccess(context.permissions, slug, context.session.user.rank),
})
);
};
const pattern = `%${escapeLike(q)}%`; const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10); const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q; const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] = const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([ await Promise.all([
db canSearch("/admin/users")
.select({ ? db
id: User.id, .select({
title: User.username, id: User.id,
subtitle: User.mail, title: User.username,
}) subtitle: User.mail,
.from(User) })
.where( .from(User)
or( .where(
like(User.username, pattern), or(
like(User.mail, pattern), like(User.username, pattern),
...(hasId ? [eq(User.id, idExact)] : []), like(User.mail, pattern),
), ...(hasId ? [eq(User.id, idExact)] : []),
) ),
.limit(PER_TYPE), )
.limit(PER_TYPE)
: Promise.resolve([]),
db canSearch("/admin/articles")
.select({ ? db
id: WebsiteArticles.id, .select({
title: WebsiteArticles.title, id: WebsiteArticles.id,
subtitle: WebsiteArticles.slug, title: WebsiteArticles.title,
}) subtitle: WebsiteArticles.slug,
.from(WebsiteArticles) })
.where( .from(WebsiteArticles)
or( .where(
like(WebsiteArticles.title, pattern), or(
like(WebsiteArticles.slug, pattern), like(WebsiteArticles.title, pattern),
), like(WebsiteArticles.slug, pattern),
) ),
.limit(PER_TYPE), )
.limit(PER_TYPE)
: Promise.resolve([]),
db canSearch("/admin/rooms")
.select({ ? db
id: Rooms.id, .select({
title: Rooms.name, id: Rooms.id,
subtitle: Rooms.ownerName, title: Rooms.name,
}) subtitle: Rooms.ownerName,
.from(Rooms) })
.where( .from(Rooms)
or( .where(
like(Rooms.name, pattern), or(
...(hasId ? [eq(Rooms.id, idExact)] : []), like(Rooms.name, pattern),
), ...(hasId ? [eq(Rooms.id, idExact)] : []),
) ),
.limit(PER_TYPE), )
.limit(PER_TYPE)
: Promise.resolve([]),
db canSearch("/admin/guilds")
.select({ ? db
id: Guilds.id, .select({
title: Guilds.name, id: Guilds.id,
subtitle: Guilds.description, title: Guilds.name,
}) subtitle: Guilds.description,
.from(Guilds) })
.where( .from(Guilds)
or( .where(
like(Guilds.name, pattern), or(
...(hasId ? [eq(Guilds.id, idExact)] : []), like(Guilds.name, pattern),
), ...(hasId ? [eq(Guilds.id, idExact)] : []),
) ),
.limit(PER_TYPE), )
.limit(PER_TYPE)
: Promise.resolve([]),
db canSearch("/admin/shop")
.select({ ? db
id: WebsiteShopArticles.id, .select({
title: WebsiteShopArticles.name, id: WebsiteShopArticles.id,
subtitle: WebsiteShopArticles.info, title: WebsiteShopArticles.name,
}) subtitle: WebsiteShopArticles.info,
.from(WebsiteShopArticles) })
.where( .from(WebsiteShopArticles)
or( .where(
like(WebsiteShopArticles.name, pattern), or(
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []), like(WebsiteShopArticles.name, pattern),
), ...(hasId
) ? [eq(WebsiteShopArticles.id, BigInt(idExact))]
.limit(PER_TYPE), : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db canSearch("/admin/rare-values")
.select({ ? db
id: WebsiteRareValues.id, .select({
title: WebsiteRareValues.name, id: WebsiteRareValues.id,
subtitle: WebsiteRareValues.itemId, title: WebsiteRareValues.name,
}) subtitle: WebsiteRareValues.itemId,
.from(WebsiteRareValues) })
.where( .from(WebsiteRareValues)
or( .where(
like(WebsiteRareValues.name, pattern), or(
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []), like(WebsiteRareValues.name, pattern),
), ...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
) ),
.limit(PER_TYPE), )
.limit(PER_TYPE)
: Promise.resolve([]),
]); ]);
const groupMap: Record< const groupMap: Record<
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { Star, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import type { AdminNavItem } from "@/lib/admin-nav";
import { parseFavoritePages } from "@/lib/admin-navigation";
import { AdminNavLink } from "./admin-nav-link";
export function AdminFavorites({
userId,
items,
}: {
userId: number;
items: AdminNavItem[];
}) {
const t = useTranslations("pages.admin.nav");
const key = `admin-favorites:${userId}`;
const [saved, setSaved] = useState<{ key: string; hrefs: string[] }>({
key: "",
hrefs: [],
});
const [storageError, setStorageError] = useState(false);
useEffect(() => {
try {
setSaved({ key, hrefs: parseFavoritePages(localStorage.getItem(key)) });
} catch {
setSaved({ key, hrefs: [] });
}
}, [key]);
const favorites =
saved.key === key
? saved.hrefs.flatMap((href) => items.find((i) => i.href === href) ?? [])
: [];
function save(hrefs: string[]) {
setSaved({ key, hrefs });
try {
localStorage.setItem(key, JSON.stringify(hrefs));
setStorageError(false);
} catch {
setStorageError(true);
}
}
return (
<section
className="mb-3 border-b border-[var(--admin-border)] pb-3"
aria-label={t("favorites")}
>
<h2
style={{ fontSize: ".75rem", margin: 0 }}
className="flex items-center gap-2 px-2 py-2 text-xs font-semibold text-[var(--admin-sidebar-muted-readable)]"
>
<Star size={14} aria-hidden />
{t("favorites")}
</h2>
{favorites.map((item) => (
<div key={item.href} className="flex items-center">
<div className="min-w-0 flex-1">
<AdminNavLink {...item} icon={<item.icon size={16} />}>
{t(item.labelKey)}
</AdminNavLink>
</div>
<button
type="button"
onClick={() =>
save(
favorites
.filter((i) => i.href !== item.href)
.map((i) => i.href),
)
}
aria-label={t("removeFavorite", { name: t(item.labelKey) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
>
<X size={14} />
</button>
</div>
))}
{favorites.length < 6 && (
<select
aria-label={t("addFavorite")}
value=""
disabled={saved.key !== key}
onChange={(event) => {
if (event.target.value)
save([...favorites.map((i) => i.href), event.target.value]);
}}
className="mt-1 h-9 w-full min-w-0 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-sidebar-background)] px-2 text-xs text-[var(--admin-sidebar-text-readable)]"
>
<option value="">{t("addFavorite")}</option>
{items
.filter((item) => !favorites.some((f) => f.href === item.href))
.map((item) => (
<option key={item.href} value={item.href}>
{t(item.labelKey)}
</option>
))}
</select>
)}
{storageError && (
<p role="status" className="px-2 text-xs">
{t("favoriteStorageError")}
</p>
)}
</section>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@@ -26,6 +27,7 @@ export function AdminMobileWrapper({
sidebar: ReactNode; sidebar: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
const t = useTranslations("pages.admin.nav");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
const mobile = useMemo(() => ({ close }), [close]); const mobile = useMemo(() => ({ close }), [close]);
@@ -57,6 +59,9 @@ export function AdminMobileWrapper({
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-label={t(open ? "closeSidebar" : "openSidebar")}
aria-expanded={open}
aria-controls="admin-sidebar-panel"
className="flex items-center justify-center w-9 h-9 rounded-lg className="flex items-center justify-center w-9 h-9 rounded-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
style={{ color: "var(--admin-sidebar-text-readable)" }} style={{ color: "var(--admin-sidebar-text-readable)" }}
@@ -67,7 +72,7 @@ export function AdminMobileWrapper({
className="text-sm font-bold" className="text-sm font-bold"
style={{ color: "var(--admin-sidebar-text-readable)" }} style={{ color: "var(--admin-sidebar-text-readable)" }}
> >
Admin Panel {t("housekeeping")}
</span> </span>
</div> </div>
@@ -75,7 +80,7 @@ export function AdminMobileWrapper({
{open && ( {open && (
<button <button
type="button" type="button"
aria-label="Close menu" aria-label={t("closeSidebar")}
className="fixed inset-0 z-40 lg:hidden" className="fixed inset-0 z-40 lg:hidden"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
@@ -84,6 +89,7 @@ export function AdminMobileWrapper({
{/* Sidebar */} {/* Sidebar */}
<div <div
id="admin-sidebar-panel"
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${ className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full" open ? "translate-x-0" : "-translate-x-full"
}`} }`}
+16 -25
View File
@@ -11,16 +11,11 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AdminNavLink } from "@/components/admin/admin-nav-link"; import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { import type { AdminNavItem } from "@/lib/admin-nav";
ADMIN_NAV_GROUPS, import type { AdminNavConfig } from "@/lib/admin-nav-config";
type AdminNavItem, import { visibleAdminNavigation } from "@/lib/admin-navigation";
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AdminFavorites } from "./admin-favorites";
const STORAGE_KEY = "admin-nav-collapsed"; const STORAGE_KEY = "admin-nav-collapsed";
@@ -67,10 +62,12 @@ function persistCollapsed(next: Record<string, boolean>) {
} }
export function AdminSidebarNav({ export function AdminSidebarNav({
userId = 0,
allowedPermissions = [], allowedPermissions = [],
isSuperAdmin = false, isSuperAdmin = false,
navConfig = {}, navConfig = {},
}: { }: {
userId?: number;
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */ /** ACL slugs the staff member holds (ignored when isSuperAdmin). */
allowedPermissions?: string[]; allowedPermissions?: string[];
isSuperAdmin?: boolean; isSuperAdmin?: boolean;
@@ -83,24 +80,12 @@ export function AdminSidebarNav({
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const allowed = useMemo( const groups = useMemo(
() => new Set(allowedPermissions), () =>
[allowedPermissions], visibleAdminNavigation({ allowedPermissions, isSuperAdmin, navConfig }),
[allowedPermissions, isSuperAdmin, navConfig],
); );
const groups = useMemo(() => {
const access = {
isSuperAdmin,
has: (slug: string) => allowed.has(slug),
};
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
.map((group) => ({
...group,
items: group.items.filter((item) => navItemIsAllowed(item, access)),
}))
.filter((group) => group.items.length > 0);
}, [allowed, isSuperAdmin, navConfig]);
const search = query.trim().toLocaleLowerCase(); const search = query.trim().toLocaleLowerCase();
const visibleGroups = useMemo( const visibleGroups = useMemo(
() => () =>
@@ -278,6 +263,12 @@ export function AdminSidebarNav({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
{!search && (
<AdminFavorites
userId={userId}
items={groups.flatMap((group) => group.items)}
/>
)}
{visibleGroups.length === 0 && ( {visibleGroups.length === 0 && (
<p <p
role="status" role="status"
+5 -2
View File
@@ -6,11 +6,14 @@ import { SearchDialog } from "@/components/admin/search-dialog";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav"; import { findAdminHub } from "@/lib/admin-nav";
import type { AdminNavigationOptions } from "@/lib/admin-navigation";
export function AdminTopbar({ export function AdminTopbar({
staff, staff,
navigation,
}: { }: {
staff: { id: number; username: string; rank: number }; staff: { id: number; username: string; rank: number };
navigation: AdminNavigationOptions;
}) { }) {
const tNav = useTranslations("pages.admin.nav"); const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs"); const tHubs = useTranslations("pages.admin.hubs");
@@ -19,7 +22,7 @@ export function AdminTopbar({
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping"); const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
return ( return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card"> <header className="flex items-center justify-between flex-wrap gap-3 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]"> <h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title} {title}
@@ -30,7 +33,7 @@ export function AdminTopbar({
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SearchDialog /> <SearchDialog navigation={navigation} />
<div className="flex items-center gap-1.5 lg:hidden"> <div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" /> <LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" /> <ThemeSwitcher variant="admin" />
+104
View File
@@ -0,0 +1,104 @@
import { count, eq, ne } from "drizzle-orm";
import { ArrowRight } from "lucide-react";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { ImportJobStore } from "@/lib/services/furni-job-store";
export async function AdminWorkQueue() {
const [{ session, permissions }, t] = await Promise.all([
getAdminContext(),
getTranslations("pages.admin.workQueue"),
]);
const allowed = (permission: string) =>
canAccess(permissions, permission, session.user.rank);
const entries: { key: string; href: string; load: () => Promise<number> }[] =
[];
if (allowed(PERMS.TICKETS_VIEW))
entries.push({
key: "tickets",
href: "/admin/tickets",
load: async () => {
const [cms, help] = await Promise.all([
db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed")),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true)),
]);
return (cms[0]?.total ?? 0) + (help[0]?.total ?? 0);
},
});
if (allowed(PERMS.ASSETS_IMPORT))
entries.push({
key: "imports",
href: "/admin/studio/furni",
load: async () =>
(await new ImportJobStore().list()).filter(
(job) =>
job.userId === Number(session.user.id) &&
(job.state === "interrupted" ||
job.items.some((item) => item.state === "failed")),
).length,
});
if (allowed(PERMS.DEVOPS_VIEW))
entries.push({
key: "errors",
href: "/admin/devops/cms-errors?status=open",
load: async () =>
new Set(
(await new ErrorStore().read()).records
.filter((record) => !record.resolved)
.map((record) => record.fingerprint),
).size,
});
const cards = await Promise.all(
entries.map(async (entry) => {
try {
return { ...entry, count: await entry.load() };
} catch (error) {
logServerError("admin.work_queue_failed", error, { queue: entry.key });
return { ...entry, count: null };
}
}),
);
if (!cards.length) return null;
return (
<section className="mb-6" aria-labelledby="work-queue">
<h2
id="work-queue"
className="mb-3 text-sm font-bold text-[var(--admin-text)]"
>
{t("title")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => (
<Link
href={card.href}
key={card.key}
className="admin-card flex items-center gap-4 no-underline focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
>
<span className="text-2xl font-bold tabular-nums text-[var(--admin-text)]">
{card.count ?? "—"}
</span>
<span className="min-w-0 flex-1">
<strong className="block text-sm text-[var(--admin-text)]">
{t(card.key)}
</strong>
<span className="text-xs text-[var(--admin-text-muted)]">
{card.count === null ? t("unavailable") : t(`${card.key}Hint`)}
</span>
</span>
<ArrowRight size={16} aria-hidden />
</Link>
))}
</div>
</section>
);
}
+206 -158
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState, useTransition } from "react";
import { useFormStatus } from "react-dom"; import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import { slugify } from "@/lib/format"; import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { MediaPicker } from "./media-picker"; import { MediaPicker } from "./media-picker";
@@ -25,6 +25,17 @@ export function ArticleForm({
}; };
}) { }) {
const t = useTranslations("pages.admin.articles.form"); const t = useTranslations("pages.admin.articles.form");
const tAction = useTranslations("pages.admin.actions");
const [dirty, setDirty] = useState(false);
const [pending, startTransition] = useTransition();
const [saveError, setSaveError] = useState(false);
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
editVersion.current++;
setDirty(true);
}
useUnsavedChanges(dirty, tAction("leaveUnsaved"));
const [title, setTitle] = useState(defaultValues?.title ?? ""); const [title, setTitle] = useState(defaultValues?.title ?? "");
const [slug, setSlug] = useState(defaultValues?.slug ?? ""); const [slug, setSlug] = useState(defaultValues?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug)); const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
@@ -39,175 +50,212 @@ export function ArticleForm({
return ( return (
<form <form
ref={formRef} ref={formRef}
action={action} onChangeCapture={markDirty}
onSubmit={(event) => {
event.preventDefault();
if (saving.current) return;
const data = new FormData(event.currentTarget);
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(false);
startTransition(async () => {
try {
await action(data);
if (editVersion.current === submittedVersion) setDirty(false);
} catch (error) {
if (
error &&
typeof error === "object" &&
"digest" in error &&
String(error.digest).startsWith("NEXT_REDIRECT")
) {
setDirty(false);
throw error;
}
setSaveError(true);
} finally {
saving.current = false;
}
});
}}
className="admin-card" className="admin-card"
style={{ display: "grid", gap: "1rem" }} style={{ display: "grid", gap: "1rem" }}
> >
<div className="grid gap-4 md:grid-cols-2"> <fieldset disabled={pending} className="contents">
<label className="block"> {saveError && (
<span className="text-xs font-medium text-[var(--admin-text-muted)]"> <p role="alert" className="text-sm">
{t("title")} {tAction("saveError")}
</span>
<input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p> </p>
) : null} )}
</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("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</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"> <label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]"> <span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")} {t("title")}
</span> </span>
<input <input
type="datetime-local" name="title"
name="publishAt" value={title}
defaultValue={defaultValues?.publishAt ?? ""} onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required required
className="input input-bordered mt-1 w-full" className="input input-bordered mt-1 w-full"
/> />
</label> </label>
) : null}
</div>
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3"> <label className="block">
<button <span className="text-xs font-medium text-[var(--admin-text-muted)]">
type="button" {t("slug")}
className="btn btn-outline" </span>
onClick={() => { <input
if (!formRef.current) return; name="slug"
const values = new FormData(formRef.current); value={slugTouched ? slug : suggestedSlug}
setPreview({ onChange={(e) => {
title: String(values.get("title") ?? ""), setSlug(e.target.value);
image: String(values.get("image") ?? ""), setSlugTouched(true);
summary: String(values.get("shortStory") ?? ""), }}
body: String(values.get("fullStory") ?? ""), placeholder={t("slugPlaceholder")}
}); className="input input-bordered mt-1 w-full"
}} />
> </label>
Preview </div>
</button>
<ArticleSaveButton label={t("save")} /> <div className="block">
</div> <span className="text-xs font-medium text-[var(--admin-text-muted)]">
<ArticlePreview data={preview} onClose={() => setPreview(null)} /> {t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
markDirty();
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
onChange={markDirty}
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</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>
<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") ?? ""),
});
}}
>
{tAction("preview")}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={pending}
aria-busy={pending}
>
{pending ? tAction("saving") : t("save")}
</button>
{dirty && (
<span
role="status"
className="self-center text-xs text-[var(--admin-text-muted)]"
>
{tAction("unsaved")}
</span>
)}
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
</fieldset>
</form> </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>
);
}
+104 -53
View File
@@ -12,8 +12,10 @@ import {
SlidersHorizontal, SlidersHorizontal,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -38,8 +40,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useDebounce } from "@/hooks/use-debounce";
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers"; import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
import { tablePageNumbers } from "@/lib/admin-table-state";
import type { DataTableColumn, PaginatedResult } from "@/types/common"; import type { DataTableColumn, PaginatedResult } from "@/types/common";
/** /**
@@ -68,29 +70,47 @@ interface DataTableProps<T extends { id: number | string }> {
export function DataTable<T extends { id: number | string }>({ export function DataTable<T extends { id: number | string }>({
data, data,
columns, columns,
searchPlaceholder = "Search...", searchPlaceholder,
bulkActions, bulkActions,
onBulkAction, onBulkAction,
exportUrl, exportUrl,
presets, presets,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const t = useTranslations("pages.admin.table");
const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(
new Set(), const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`;
); const [selection, setSelection] = useState<{
const [searchValue, setSearchValue] = useState( scope: string;
searchParams.get("search") || "", ids: Set<number | string>;
); }>({ scope, ids: new Set() });
const selectedIds =
selection.scope === scope ? selection.ids : new Set<number | string>();
function setSelectedIds(
value:
| Set<number | string>
| ((prev: Set<number | string>) => Set<number | string>),
) {
setSelection({
scope,
ids: typeof value === "function" ? value(selectedIds) : value,
});
}
const urlSearch = searchParams.get("search") ?? "";
const [draft, setDraft] = useState({ base: urlSearch, value: urlSearch });
if (draft.base !== urlSearch) setDraft({ base: urlSearch, value: urlSearch });
const searchValue = draft.base === urlSearch ? draft.value : urlSearch;
const [bulkBusy, setBulkBusy] = useState(false);
const bulkLock = useRef(false);
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set()); const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
const currentSort = searchParams.get("sort") || ""; const currentSort = searchParams.get("sort") || "";
const currentOrder = searchParams.get("order") || "desc"; const currentOrder = searchParams.get("order") || "desc";
const debouncedSearch = useDebounce(searchValue, 300);
const isFirstRender = useRef(true);
// Collect active filters from URL // Collect active filters from URL
const filterColumns = columns.filter( const filterColumns = columns.filter(
(col) => col.filterOptions && col.filterOptions.length > 0, (col) => col.filterOptions && col.filterOptions.length > 0,
@@ -110,27 +130,31 @@ export function DataTable<T extends { id: number | string }>({
else params.delete(key); else params.delete(key);
} }
startTransition(() => { startTransition(() => {
router.push(`?${params.toString()}`); router.push(`?${params.toString()}`, { scroll: false });
}); });
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
if (isFirstRender.current) { if (searchValue === urlSearch) return;
isFirstRender.current = false; const timer = setTimeout(() => {
return; const params = new URLSearchParams(searchParams.toString());
} if (searchValue) params.set("search", searchValue);
updateParams({ search: debouncedSearch, page: "1" }); else params.delete("search");
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps params.set("page", "1");
startTransition(() =>
router.replace(`?${params.toString()}`, { scroll: false }),
);
}, 300);
return () => clearTimeout(timer);
}, [searchValue, urlSearch, searchParams, router]);
function handleSearch(value: string) { function handleSearch(value: string) {
setSearchValue(value); setDraft({ base: urlSearch, value });
} }
function handleSort(key: string) { function handleSort(key: string) {
const newOrder = const newOrder =
currentSort === key && currentOrder === "asc" ? "desc" : "asc"; currentSort === key && currentOrder === "asc" ? "desc" : "asc";
updateParams({ sort: key, order: newOrder }); updateParams({ sort: key, order: newOrder, page: "1" });
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
@@ -173,10 +197,33 @@ export function DataTable<T extends { id: number | string }>({
} }
async function handleBulkAction(action: string) { async function handleBulkAction(action: string) {
if (selectedIds.size === 0 || !onBulkAction) return; if (
await onBulkAction(action, Array.from(selectedIds)); selectedIds.size === 0 ||
setSelectedIds(new Set()); !onBulkAction ||
router.refresh(); bulkLock.current ||
isPending
)
return;
const definition = bulkActions?.find((item) => item.value === action);
if (
definition?.variant === "destructive" &&
!window.confirm(
t("confirmBulk", { action: definition.label, count: selectedIds.size }),
)
)
return;
bulkLock.current = true;
setBulkBusy(true);
try {
await onBulkAction(action, Array.from(selectedIds));
setSelectedIds(new Set());
router.refresh();
} catch {
toast.error(t("bulkError"));
} finally {
bulkLock.current = false;
setBulkBusy(false);
}
} }
function getSortIcon(key: string) { function getSortIcon(key: string) {
@@ -206,7 +253,7 @@ export function DataTable<T extends { id: number | string }>({
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4 min-w-0" aria-busy={isPending || bulkBusy}>
{/* Preset filter chips */} {/* Preset filter chips */}
{presets && presets.length > 0 && ( {presets && presets.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
@@ -236,9 +283,11 @@ export function DataTable<T extends { id: number | string }>({
<div className="relative flex-1 min-w-[200px] max-w-sm"> <div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder ?? t("search")}
aria-label={searchPlaceholder ?? t("search")}
value={searchValue} value={searchValue}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
style={{ paddingLeft: 36 }}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -249,7 +298,7 @@ export function DataTable<T extends { id: number | string }>({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
Filters {t("filters")}
{activeFilterCount > 0 && ( {activeFilterCount > 0 && (
<Badge <Badge
variant="secondary" variant="secondary"
@@ -263,7 +312,7 @@ export function DataTable<T extends { id: number | string }>({
<PopoverContent className="w-72" align="start"> <PopoverContent className="w-72" align="start">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Filters</h4> <h4 className="text-sm font-medium">{t("filters")}</h4>
{activeFilterCount > 0 && ( {activeFilterCount > 0 && (
<Button <Button
variant="ghost" variant="ghost"
@@ -271,7 +320,7 @@ export function DataTable<T extends { id: number | string }>({
className="h-7 px-2 text-xs" className="h-7 px-2 text-xs"
onClick={clearAllFilters} onClick={clearAllFilters}
> >
Clear all {t("clearAll")}
</Button> </Button>
)} )}
</div> </div>
@@ -291,7 +340,7 @@ export function DataTable<T extends { id: number | string }>({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">All</SelectItem> <SelectItem value="__all__">{t("all")}</SelectItem>
{col.filterOptions?.map((opt) => ( {col.filterOptions?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
@@ -320,6 +369,7 @@ export function DataTable<T extends { id: number | string }>({
{col.label}: {opt?.label || val} {col.label}: {opt?.label || val}
<button <button
type="button" type="button"
aria-label={t("removeFilter", { name: col.label })}
onClick={() => handleFilterChange(col, "__all__")} onClick={() => handleFilterChange(col, "__all__")}
className="ml-0.5 rounded-full p-0.5 hover:bg-accent" className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
> >
@@ -336,12 +386,12 @@ export function DataTable<T extends { id: number | string }>({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
Columns {t("columns")}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-48" align="start"> <PopoverContent className="w-48" align="start">
<div className="space-y-1"> <div className="space-y-1">
<h4 className="text-sm font-medium mb-2">Toggle columns</h4> <h4 className="text-sm font-medium mb-2">{t("columns")}</h4>
{columns.map((col) => ( {columns.map((col) => (
<div <div
key={col.key} key={col.key}
@@ -349,6 +399,7 @@ export function DataTable<T extends { id: number | string }>({
> >
<Checkbox <Checkbox
checked={!hiddenColumns.has(col.key)} checked={!hiddenColumns.has(col.key)}
aria-label={col.label}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setHiddenColumns((prev) => { setHiddenColumns((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -375,7 +426,7 @@ export function DataTable<T extends { id: number | string }>({
<SelectContent> <SelectContent>
{PER_PAGE_OPTIONS.map((n) => ( {PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}> <SelectItem key={n} value={String(n)}>
{n} / page {t("perPage", { count: n })}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -393,13 +444,14 @@ export function DataTable<T extends { id: number | string }>({
{bulkActions && selectedIds.size > 0 && ( {bulkActions && selectedIds.size > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{selectedIds.size} selected {t("selected", { count: selectedIds.size })}
</span> </span>
{bulkActions.map((action) => ( {bulkActions.map((action) => (
<Button <Button
key={action.value} key={action.value}
variant={action.variant || "default"} variant={action.variant || "default"}
size="sm" size="sm"
disabled={bulkBusy || isPending}
onClick={() => handleBulkAction(action.value)} onClick={() => handleBulkAction(action.value)}
> >
{action.label} {action.label}
@@ -409,7 +461,7 @@ export function DataTable<T extends { id: number | string }>({
)} )}
<div className="ml-auto text-sm text-muted-foreground"> <div className="ml-auto text-sm text-muted-foreground">
{data.total} results {t("results", { count: data.total })}
</div> </div>
</div> </div>
@@ -425,6 +477,8 @@ export function DataTable<T extends { id: number | string }>({
selectedIds.size === data.rows.length && selectedIds.size === data.rows.length &&
data.rows.length > 0 data.rows.length > 0
} }
aria-label={t("selectAll")}
disabled={isPending || bulkBusy}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
</TableHead> </TableHead>
@@ -459,7 +513,7 @@ export function DataTable<T extends { id: number | string }>({
} }
className="h-24 text-center text-muted-foreground" className="h-24 text-center text-muted-foreground"
> >
No results found. {t("empty")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -473,6 +527,8 @@ export function DataTable<T extends { id: number | string }>({
<TableCell> <TableCell>
<Checkbox <Checkbox
checked={selectedIds.has(row.id)} checked={selectedIds.has(row.id)}
aria-label={t("selectRow", { id: String(row.id) })}
disabled={isPending || bulkBusy}
onCheckedChange={() => toggleSelect(row.id)} onCheckedChange={() => toggleSelect(row.id)}
/> />
</TableCell> </TableCell>
@@ -504,31 +560,25 @@ export function DataTable<T extends { id: number | string }>({
{data.lastPage > 1 && ( {data.lastPage > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Page {data.page} of {data.lastPage} {t("pageOf", { page: data.page, last: data.lastPage })}
</p> </p>
<div className="flex items-center gap-1 flex-wrap justify-end"> <div className="flex items-center gap-1 flex-wrap justify-end">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
disabled={data.page <= 1} aria-label={t("previous")}
disabled={data.page <= 1 || isPending}
onClick={() => handlePageChange(data.page - 1)} onClick={() => handlePageChange(data.page - 1)}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => { {tablePageNumbers(data.page, data.lastPage).map((pageNum) => {
let pageNum: number;
if (data.lastPage <= 7) {
pageNum = i + 1;
} else if (data.page <= 4) {
pageNum = i + 1;
} else if (data.page >= data.lastPage - 3) {
pageNum = data.lastPage - 6 + i;
} else {
pageNum = data.page - 3 + i;
}
return ( return (
<Button <Button
key={pageNum} key={pageNum}
aria-label={t("page", { page: pageNum })}
aria-current={pageNum === data.page ? "page" : undefined}
disabled={isPending}
variant={pageNum === data.page ? "default" : "outline"} variant={pageNum === data.page ? "default" : "outline"}
size="icon" size="icon"
onClick={() => handlePageChange(pageNum)} onClick={() => handlePageChange(pageNum)}
@@ -540,7 +590,8 @@ export function DataTable<T extends { id: number | string }>({
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
disabled={data.page >= data.lastPage} aria-label={t("next")}
disabled={data.page >= data.lastPage || isPending}
onClick={() => handlePageChange(data.page + 1)} onClick={() => handlePageChange(data.page + 1)}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
+13 -4
View File
@@ -57,13 +57,17 @@ export function RichText({
defaultValue = "", defaultValue = "",
placeholder, placeholder,
rows = 10, rows = 10,
onChange,
}: { }: {
name: string; name: string;
defaultValue?: string; defaultValue?: string;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
onChange?: () => void;
}) { }) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const changeCallback = useRef(onChange);
changeCallback.current = onChange;
const reactId = useId(); const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':'). // Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
@@ -94,11 +98,16 @@ export function RichText({
on: (ev: string, cb: () => void) => void; on: (ev: string, cb: () => void) => void;
getContent: () => string; getContent: () => string;
}) => { }) => {
const sync = () => { const sync = (notify: boolean) => {
if (textareaRef.current) const field = textareaRef.current;
textareaRef.current.value = editor.getContent(); if (!field) return;
const html = editor.getContent();
const changed = field.value !== html;
field.value = html;
if (notify && changed) changeCallback.current?.();
}; };
editor.on("change keyup undo redo SetContent", sync); editor.on("SetContent", () => sync(false));
editor.on("change keyup undo redo", () => sync(true));
}, },
}) })
.then(() => { .then(() => {
+72 -8
View File
@@ -13,8 +13,17 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
import {
type AdminNavigationOptions,
visibleAdminNavigation,
} from "@/lib/admin-navigation";
type SearchResult = AdminSearchResult; type SearchResult = AdminSearchResult;
@@ -23,13 +32,30 @@ type SearchResponse = {
results: SearchResult[]; results: SearchResult[];
}; };
export function SearchDialog() { export function SearchDialog({
navigation,
}: {
navigation: AdminNavigationOptions;
}) {
const tNav = useTranslations("pages.admin.nav");
const getPages = (queryPlaceholder: string) =>
visibleAdminNavigation(navigation)
.flatMap((group) =>
group.items.map((item) => ({ ...item, group: tNav(group.labelKey) })),
)
.filter((item) =>
[tNav(item.labelKey), item.group, item.href]
.join(" ")
.toLocaleLowerCase()
.includes(queryPlaceholder),
);
const t = useTranslations("pages.admin.search"); const t = useTranslations("pages.admin.search");
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const debouncedQuery = useDebounce(query, 250); const debouncedQuery = useDebounce(query, 250);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -44,6 +70,8 @@ export function SearchDialog() {
const fetchResults = useCallback(async (q: string) => { const fetchResults = useCallback(async (q: string) => {
abortRef.current?.abort(); abortRef.current?.abort();
setFailed(false);
setResults([]);
if (q.length < 2) { if (q.length < 2) {
setResults([]); setResults([]);
setLoading(false); setLoading(false);
@@ -56,21 +84,27 @@ export function SearchDialog() {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal, signal: controller.signal,
}); });
if (!res.ok) return; if (!res.ok) throw new Error("Search request failed");
const data: SearchResponse = await res.json(); const data: SearchResponse = await res.json();
if (abortRef.current !== controller) return; if (abortRef.current !== controller || controller.signal.aborted) return;
if (data.ok) setResults(data.results); if (data.ok) setResults(data.results);
else setFailed(true);
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return; if (error instanceof DOMException && error.name === "AbortError") return;
if (abortRef.current === controller) setResults([]); if (abortRef.current === controller) {
setResults([]);
setFailed(true);
}
} finally { } finally {
if (abortRef.current === controller) setLoading(false); if (abortRef.current === controller) setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!open) return;
fetchResults(debouncedQuery); fetchResults(debouncedQuery);
}, [debouncedQuery, fetchResults]); return () => abortRef.current?.abort();
}, [debouncedQuery, fetchResults, open]);
useEffect(() => { useEffect(() => {
return () => abortRef.current?.abort(); return () => abortRef.current?.abort();
@@ -94,13 +128,15 @@ export function SearchDialog() {
router.push(url); router.push(url);
} }
const grouped = groupResults(results); const grouped = groupResults(query === debouncedQuery ? results : []);
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={t("placeholder")}
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" 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} /> <SearchIcon size={14} />
@@ -110,6 +146,10 @@ export function SearchDialog() {
</kbd> </kbd>
</button> </button>
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden"> <DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
<DialogDescription className="sr-only">
{t("description")}
</DialogDescription>
<Command <Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
shouldFilter={false} shouldFilter={false}
@@ -126,7 +166,31 @@ export function SearchDialog() {
… …
</div> </div>
) : null} ) : null}
<CommandEmpty>{t("noResults")}</CommandEmpty> {!loading && !failed && (
<CommandEmpty>{t("noResults")}</CommandEmpty>
)}
{failed && (
<p role="alert" className="p-4 text-sm">
{t("loadError")}
</p>
)}
{pages.length > 0 && (
<CommandGroup heading={t("pages")}>
{pages.map((item) => (
<CommandItem
key={item.href}
value={item.href}
onSelect={() => handleSelect(item.href)}
>
<item.icon size={16} />
<span>{tNav(item.labelKey)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{item.group}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{Object.entries(grouped).map(([group, items]) => ( {Object.entries(grouped).map(([group, items]) => (
<CommandGroup key={group} heading={groupLabels[group] ?? group}> <CommandGroup key={group} heading={groupLabels[group] ?? group}>
{items.map((item) => ( {items.map((item) => (
+8 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type SearchInputProps = { type SearchInputProps = {
@@ -15,21 +17,24 @@ export function SearchInput({
placeholder, placeholder,
onClear, onClear,
}: SearchInputProps) { }: SearchInputProps) {
const t = useTranslations("pages.admin.table");
return ( return (
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={placeholder} aria-label={placeholder ?? t("search")}
placeholder={placeholder ?? t("search")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="pl-9 h-9" style={{ paddingLeft: 36, paddingRight: 40 }}
className="pl-9 pr-10 h-9"
/> />
{value && onClear && ( {value && onClear && (
<button <button
type="button" type="button"
onClick={onClear} onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search" aria-label={t("clearSearch")}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
+35 -15
View File
@@ -1,26 +1,29 @@
"use client"; "use client";
import { useFormatter, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs"; import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({ export function FurnitureJobHistory({
state, state,
}: { }: {
state: ReturnType<typeof useFurnitureJobs>; state: ReturnType<typeof useFurnitureJobs>;
}) { }) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
return ( return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"> <details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer"> <summary className="cursor-pointer">
Import history ·{" "} {t("summary", {
{ count: state.jobs.filter(
state.jobs.filter( (job) => job.state === "queued" || job.state === "running",
(j) => j.state === "queued" || j.state === "running", ).length,
).length })}
}{" "}
active
</summary> </summary>
<div className="max-h-72 space-y-2 overflow-auto py-2"> <div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>} {state.error && <p role="alert">{state.error}</p>}
{!state.jobs.length && <p>No background imports yet.</p>} {!state.jobs.length && <p>{t("empty")}</p>}
{state.jobs.map((job) => { {state.jobs.map((job) => {
const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"), const done = job.items.filter((i) => i.state === "done"),
failed = job.items.filter((i) => i.state === "failed"); failed = job.items.filter((i) => i.state === "failed");
return ( return (
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
className="rounded border border-[var(--admin-border)] p-2" className="rounded border border-[var(--admin-border)] p-2"
> >
<summary className="cursor-pointer"> <summary className="cursor-pointer">
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "} {format.dateTime(new Date(job.createdAt), {
{done.length}/{job.items.length} imported · {failed.length}{" "} dateStyle: "short",
failed timeStyle: "short",
})}{" "}
· {t(job.state)} ·{" "}
{t("result", {
done: done.length,
total: job.items.length,
failed: failed.length,
})}
</summary> </summary>
<progress
className="mt-2 h-2 w-full"
value={done.length + failed.length}
max={Math.max(1, job.items.length)}
aria-label={t("progress")}
/>
<ul className="space-y-1 py-2"> <ul className="space-y-1 py-2">
{job.items.map((item) => ( {job.items.map((item) => (
<li key={item.classname}> <li key={item.classname}>
<strong>{item.classname}</strong> · {item.state} <strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""} {item.itemId ? ` · ID ${item.itemId}` : ""}
{item.error && ( {item.error && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
</li> </li>
))} ))}
</ul> </ul>
{failed.length > 0 && job.state !== "running" && ( {retryItems.length > 0 && (
<Button <Button
variant="outline" variant="outline"
disabled={state.busy} disabled={state.busy}
onClick={() => onClick={() =>
void state.submit(failed, { void state.submit(retryItems, {
sourceId: job.sourceId, sourceId: job.sourceId,
mode: job.mode, mode: job.mode,
translate: job.translate, translate: job.translate,
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
}) })
} }
> >
Retry failed items only {t(
job.state === "interrupted"
? "retryUnfinished"
: "retryFailed",
)}
</Button> </Button>
)} )}
</details> </details>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) { export function useFurnitureJobs(onComplete: () => void) {
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
callback.current = onComplete; callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null); const request = useRef<{ payload: string; id: string } | null>(null);
const submitting = useRef(false); const submitting = useRef(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try { try {
const response = await adminFetch("/api/admin/studio/import-jobs"); const response = await adminFetch("/api/admin/studio/import-jobs");
const data = await response.json(); const data = await response.json();
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
setError( setError(
error instanceof Error ? error.message : "Cannot load import history", error instanceof Error ? error.message : "Cannot load import history",
); );
} finally {
refreshing.current = false;
} }
}, []); }, []);
useEffect(() => { useVisiblePolling(refresh, 5000);
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => clearInterval(timer);
}, [refresh]);
const submit = async ( const submit = async (
items: ImportJobItem[], items: ImportJobItem[],
options: { options: {
+20 -5
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTranslations } from "next-intl";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
@@ -35,13 +36,19 @@ interface RunOptions {
* run(() => deleteNews(id), { redirectTo: '/admin/news' }) * run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/ */
export function useServerAction(options: UseServerActionOptions = {}) { export function useServerAction(options: UseServerActionOptions = {}) {
const t = useTranslations("pages.admin.actions");
const locked = useRef(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
function run(action: () => Promise<unknown>, opts: RunOptions = {}) { function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
if (locked.current) return;
locked.current = true;
setFieldErrors({});
const { const {
successMessage = "Saved successfully.", successMessage = t("saved"),
errorMessage = "An error occurred.", errorMessage = t("error"),
redirectTo, redirectTo,
onSuccess, onSuccess,
onError, onError,
@@ -61,16 +68,24 @@ export function useServerAction(options: UseServerActionOptions = {}) {
else router.refresh(); else router.refresh();
} }
} else { } else {
toast.error(result.error || errorMessage); setFieldErrors(result.fieldErrors ?? {});
toast.error(result.error || errorMessage, {
description:
Object.values(result.fieldErrors ?? {})
.flat()
.join(" · ") || undefined,
});
onError?.(); onError?.();
} }
} catch (err) { } catch (err) {
console.error("[useServerAction] Unexpected error:", err); console.error("[useServerAction] Unexpected error:", err);
toast.error(errorMessage); toast.error(errorMessage);
onError?.(); onError?.();
} finally {
locked.current = false;
} }
}); });
} }
return { run, isPending }; return { run, isPending, fieldErrors };
} }
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
/** Protect browser close/reload and same-tab link navigation while editing. */
export function useUnsavedChanges(dirty: boolean, message: string) {
useEffect(() => {
if (!dirty) return;
function beforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function click(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const target = new URL(link.href, location.href);
if (
target.origin === location.origin &&
target.pathname === location.pathname &&
target.search === location.search
)
return;
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
};
}, [dirty, message]);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
/** Refresh after the prior request settles; pause while the tab is hidden. */
export function useVisiblePolling(
refresh: () => Promise<unknown>,
delay: number,
) {
const callback = useRef(refresh);
callback.current = refresh;
useEffect(() => {
let stopped = false,
running = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function tick() {
if (stopped || running) return;
clearTimeout(timer);
if (document.visibilityState === "hidden") return;
running = true;
try {
await callback.current();
} finally {
running = false;
if (!stopped) timer = setTimeout(tick, delay);
}
}
function visibility() {
if (document.visibilityState === "visible") void tick();
else clearTimeout(timer);
}
void tick();
document.addEventListener("visibilitychange", visibility);
return () => {
stopped = true;
clearTimeout(timer);
document.removeEventListener("visibilitychange", visibility);
};
}, [delay]);
}
+49
View File
@@ -0,0 +1,49 @@
import { createTranslator } from "next-intl";
import { expect, it } from "vitest";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { visibleAdminNavigation } from "@/lib/admin-navigation";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
it.each([
["en", en],
["it", itMessages],
] as const)(
"renders housekeeping navigation and shared controls in %s",
(locale, messages) => {
const errors: unknown[] = [];
const t = createTranslator({
locale,
messages: messages as unknown as Record<string, string>,
onError: (error) => errors.push(error),
});
const translate = t as unknown as (
key: string,
values?: Record<string, string | number>,
) => string;
const keys = new Set([
...ADMIN_NAV_GROUPS.flatMap((g) => [
g.labelKey,
...g.items.map((i) => i.labelKey),
]),
...visibleAdminNavigation({
allowedPermissions: [],
isSuperAdmin: true,
navConfig: {},
}).map((g) => g.labelKey),
]);
for (const key of keys) translate(`pages.admin.nav.${key}`);
for (const area of ["actions", "table", "cmsErrors", "workQueue"] as const)
for (const key of Object.keys(en.pages.admin[area]))
translate(`pages.admin.${area}.${key}`, {
count: 2,
action: "Save",
page: 2,
last: 7,
id: "1",
name: "Name",
groups: 1,
});
expect(errors).toEqual([]);
},
);
+13 -1
View File
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
icon: Server, icon: Server,
prefixes: ["/admin/devops"], prefixes: ["/admin/devops"],
tabs: [ tabs: [
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] }, {
href: "/admin/devops",
labelKey: "devops",
match: ["/admin/devops"],
},
{ href: "/admin/devops/errors", labelKey: "errors" }, { href: "/admin/devops/errors", labelKey: "errors" },
{ href: "/admin/devops/cms-errors", labelKey: "cmsErrors" },
], ],
}, },
]; ];
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: Server, icon: Server,
permission: PERMS.DEVOPS_VIEW, permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"], matchPrefixes: ["/admin/devops"],
matchExcludePrefixes: ["/admin/devops/cms-errors"],
},
{
href: "/admin/devops/cms-errors",
labelKey: "cmsErrors",
icon: AlertTriangle,
permission: PERMS.DEVOPS_VIEW,
}, },
{ {
href: "/admin/alerts", href: "/admin/alerts",
+46
View File
@@ -0,0 +1,46 @@
import { expect, it } from "vitest";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { parseFavoritePages, visibleAdminNavigation } from "./admin-navigation";
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
it("retains each route and its permission definition once after regrouping", () => {
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
expect(actual).toHaveLength(original.length);
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
for (const item of original) expect(actual).toContain(item);
expect(
visibleAdminNavigation(access)
.find((g) => g.labelKey === "catalogFurniture")
?.items.some((i) => i.href === "/admin/studio"),
).toBe(true);
});
it("respects hidden groups, owner order and ACL in every consumer", () => {
const configured = visibleAdminNavigation({
...access,
navConfig: {
groupOrder: ["monitoring"],
hiddenGroups: ["economy"],
hiddenItems: ["/admin/studio"],
},
});
expect(configured[0].labelKey).toBe("monitoring");
expect(
configured
.flatMap((g) => g.items)
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
).toBe(false);
const limited = visibleAdminNavigation({
...access,
isSuperAdmin: false,
}).flatMap((g) => g.items);
expect(limited.every((i) => !i.permission)).toBe(true);
});
it("rejects malformed favorites, external links and duplicates", () => {
expect(parseFavoritePages("{")).toEqual([]);
expect(
parseFavoritePages(
'["/admin/users","https://evil.test","/admin/users",null,"/admin?token=x"]',
),
).toEqual(["/admin/users"]);
});
+180
View File
@@ -0,0 +1,180 @@
import {
Activity,
Cog,
LayoutDashboard,
Newspaper,
Package,
ShoppingCart,
Users,
UsersRound,
} from "lucide-react";
import {
ADMIN_NAV_GROUPS,
type AdminNavGroup,
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
export type AdminNavigationOptions = {
allowedPermissions: string[];
isSuperAdmin: boolean;
navConfig: AdminNavConfig;
};
const sections = [
{ labelKey: "overview", icon: LayoutDashboard, roots: [""] },
{
labelKey: "peopleSupport",
icon: Users,
roots: [
"users",
"online",
"tickets",
"help-tickets",
"bans",
"ip",
"vpn",
"wordfilter",
"moderation",
"rooms",
],
},
{
labelKey: "catalogFurniture",
icon: Package,
roots: [
"studio",
"catalog",
"import",
"items",
"rare-values",
"badges",
"achievements",
"sounds",
],
},
{
labelKey: "communityContent",
icon: Newspaper,
roots: [
"articles",
"photos",
"events",
"polls",
"guilds",
"media",
"radio",
"ads",
"banners",
"navigation",
"help-questions",
"writeable-boxes",
"tags",
"prefixes",
],
},
{
labelKey: "economy",
icon: ShoppingCart,
roots: [
"shop",
"marketplace",
"transactions",
"vouchers",
"subscriptions",
"calendar",
],
},
{
labelKey: "staffPermissions",
icon: UsersRound,
roots: ["teams", "applications", "permissions"],
},
{
labelKey: "system",
icon: Cog,
roots: [
"settings",
"theme",
"theme-builder",
"maintenance",
"favicon",
"email-templates",
"translations",
"emulator",
"housekeeping",
"commandocentrum",
],
},
{
labelKey: "monitoring",
icon: Activity,
roots: ["logs", "analytics", "devops", "alerts"],
},
];
/** Owner order takes precedence; hidden entries are filtered before regrouping. */
export function visibleAdminNavigation(
options: AdminNavigationOptions,
): AdminNavGroup[] {
const allowed = new Set(options.allowedPermissions);
const groups = applyAdminNavConfig(ADMIN_NAV_GROUPS, options.navConfig)
.map((group) => ({
...group,
items: group.items.filter((item) =>
navItemIsAllowed(item, {
isSuperAdmin: options.isSuperAdmin,
has: (slug) => allowed.has(slug),
}),
),
}))
.filter((group) => group.items.length);
if (
options.navConfig.groupOrder?.length ||
Object.values(options.navConfig.itemOrder ?? {}).some(
(order) => order.length,
)
)
return groups;
const remaining = new Map(
groups.flatMap((group) => group.items).map((item) => [item.href, item]),
);
const organized = sections
.map((section) => {
const items = [...remaining.values()].filter((item) =>
section.roots.includes(item.href.split("/")[2] ?? ""),
);
for (const item of items) remaining.delete(item.href);
return { labelKey: section.labelKey, icon: section.icon, items };
})
.filter((group) => group.items.length);
// New routes remain discoverable even before being assigned to an activity.
if (remaining.size)
organized.push({
labelKey: "tools",
icon: Cog,
items: [...remaining.values()],
});
return organized;
}
export function parseFavoritePages(raw: string | null): string[] {
try {
const value: unknown = JSON.parse(raw ?? "[]");
return Array.isArray(value)
? [
...new Set(
value.filter(
(entry): entry is string =>
typeof entry === "string" &&
/^\/admin(?:\/[^?#]*)?$/.test(entry),
),
),
].slice(0, 6)
: [];
} catch {
return [];
}
}
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
queried: [] as unknown[],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
handler(request, {
session: { user: { id: 1, rank: 7 } },
permissions: { isSuperAdmin: false },
}),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
}));
vi.mock("@/lib/db", () => {
const tables = Object.fromEntries(
[
"User",
"Rooms",
"Guilds",
"WebsiteArticles",
"WebsiteRareValues",
"WebsiteShopArticles",
].map((name) => [
name,
{
name,
id: `${name}Id`,
username: `${name}Name`,
mail: `${name}Mail`,
slug: `${name}Slug`,
ownerName: `${name}Owner`,
itemId: `${name}Item`,
description: `${name}Description`,
},
]),
);
return {
...tables,
db: {
select() {
let table: unknown;
const query = {
from(value: unknown) {
table = value;
return query;
},
where() {
return query;
},
async limit() {
state.queried.push(table);
return [{ id: 1, title: "Match", subtitle: "Data" }];
},
};
return query;
},
},
};
});
import { GET } from "@/app/api/admin/search/route";
import { PERMS } from "@/lib/permission-slugs";
beforeEach(() => {
state.allowed.clear();
state.queried.length = 0;
});
const request = () =>
({
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
}) as Parameters<typeof GET>[0];
it("does not query or expose categories without their view permission", async () => {
const response = await GET(request(), {});
expect((await response.json()).results).toEqual([]);
expect(state.queried).toHaveLength(0);
});
it("searches only the permitted category", async () => {
state.allowed.add(PERMS.NEWS_VIEW);
const response = await GET(request(), {});
const body = await response.json();
expect(body.results).toHaveLength(1);
expect(body.results[0].type).toBe("articles");
expect(state.queried).toHaveLength(1);
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { tablePageNumbers } from "./admin-table-state";
it.each([
[1, 1],
[1, 7],
[6, 7],
[7, 7],
[50, 100],
[100, 100],
])("includes the current page %i/%i and stays in bounds", (page, last) => {
const pages = tablePageNumbers(page, last);
expect(pages).toContain(page);
expect(pages.length).toBeLessThanOrEqual(5);
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
expect(new Set(pages).size).toBe(pages.length);
});
+6
View File
@@ -0,0 +1,6 @@
/** A centered, bounded window including the current page, also at either end. */
export function tablePageNumbers(page: number, last: number): number[] {
const count = Math.min(5, Math.max(1, last));
const start = Math.max(1, Math.min(page - 2, last - count + 1));
return Array.from({ length: count }, (_, index) => start + index);
}
+32
View File
@@ -0,0 +1,32 @@
import { cache } from "react";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
export const loadAdminNavigation = cache(async () => {
const [{ session, permissions }, raw] = await Promise.all([
getAdminContext(),
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
]);
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
session.user.rank,
);
return {
isSuperAdmin: permissions.isSuperAdmin,
allowedPermissions: permissions.isSuperAdmin
? []
: collectNavPermissionSlugs().filter(
(slug) =>
canAccess(permissions, slug, session.user.rank) ||
(hasDashboard &&
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
),
navConfig: parseAdminNavConfig(raw),
};
});
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import type { ImportJob } from "./import-job";
import { retryableJobItems } from "./import-job-retry";
const job = (state: ImportJob["state"]) =>
({
state,
items: ["done", "failed", "pending", "running"].map((state) => ({
state,
classname: state,
})),
}) as ImportJob;
it("only retries failures after a completed job", () =>
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
"failed",
]));
it("recovers all unfinished items from an interrupted job", () =>
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
"failed",
"pending",
"running",
]));
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
expect(retryableJobItems(job(state))).toEqual([]),
);
+10
View File
@@ -0,0 +1,10 @@
import type { ImportJob } from "./import-job";
/** Never requeue an active job or furniture already imported successfully. */
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
if (job.state === "running" || job.state === "queued") return [];
return job.items.filter(
(item) =>
item.state === "failed" ||
(job.state === "interrupted" && item.state !== "done"),
);
}
+4 -4
View File
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain( expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
"/admin/menu", "/admin/menu",
); );
expect( expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"), "applyAdminNavConfig",
).toContain("applyAdminNavConfig"); );
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain( expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
"ADMIN_NAV_CONFIG_KEY", "ADMIN_NAV_CONFIG_KEY",
); );
}); });
+109 -4
View File
@@ -1048,7 +1048,7 @@
"system": "System", "system": "System",
"hubTools": "Tools", "hubTools": "Tools",
"hubObservability": "Observability", "hubObservability": "Observability",
"commandocentrum": "Commandocentrum", "commandocentrum": "Command center",
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Alerts", "alerts": "Alerts",
"logs": "Logs", "logs": "Logs",
@@ -1094,7 +1094,19 @@
"guilds": "Guilds", "guilds": "Guilds",
"shop": "Shop", "shop": "Shop",
"hint": "Start typing to search across users, articles, rooms, guilds and shop items" "hint": "Start typing to search across users, articles, rooms, guilds and shop items"
} },
"peopleSupport": "Users and support",
"catalogFurniture": "Catalog and furniture",
"communityContent": "Community and content",
"staffPermissions": "Staff and access",
"favorites": "Favorites",
"addFavorite": "Add favorite",
"removeFavorite": "Remove {name} from favorites",
"favoriteStorageError": "Favorites are available for this session; browser storage is unavailable.",
"cmsErrors": "CMS errors",
"themeBuilder": "Theme builder",
"openSidebar": "Open menu",
"closeSidebar": "Close menu"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -1252,7 +1264,8 @@
}, },
"devops": { "devops": {
"noEmulatorErrors": "No emulator errors found", "noEmulatorErrors": "No emulator errors found",
"noRecentErrors": "No recent errors" "noRecentErrors": "No recent errors",
"cmsErrors": "CMS error center"
}, },
"tags": { "tags": {
"title": "Tags", "title": "Tags",
@@ -2790,7 +2803,8 @@
"devops": "DevOps", "devops": "DevOps",
"errors": "Errors", "errors": "Errors",
"alerts": "Alerts", "alerts": "Alerts",
"toolsGroup": "Tools" "toolsGroup": "Tools",
"cmsErrors": "CMS errors"
}, },
"events": "Events", "events": "Events",
"eventsSubtitle": "Hotel events and event types", "eventsSubtitle": "Hotel events and event types",
@@ -3284,6 +3298,97 @@
"less": "Less", "less": "Less",
"more": "More" "more": "More"
} }
},
"search": {
"pages": "Pages and tools",
"description": "Find an admin page or search hotel records.",
"loadError": "Records could not be loaded. You can still open the pages above.",
"placeholder": "Search pages, users, rooms…"
},
"table": {
"search": "Search…",
"clearSearch": "Clear search",
"filters": "Filters",
"clearAll": "Clear all",
"all": "All",
"columns": "Columns",
"perPage": "{count} / page",
"selected": "{count} selected",
"results": "{count} results",
"empty": "No results match your search or filters.",
"selectAll": "Select all rows on this page",
"selectRow": "Select row {id}",
"removeFilter": "Remove filter: {name}",
"previous": "Previous page",
"next": "Next page",
"page": "Page {page}",
"pageOf": "Page {page} of {last}",
"bulkError": "The operation failed. Your selection has been kept; check the result before retrying.",
"confirmBulk": "{action} on {count} selected rows?"
},
"actions": {
"saved": "Saved successfully.",
"error": "The operation could not be completed.",
"saving": "Saving…",
"saveError": "Save failed. Your changes are still here; please try again.",
"unsaved": "Unsaved changes",
"leaveUnsaved": "You have unsaved changes. Leave this page?",
"preview": "Preview"
},
"workQueue": {
"title": "Work to review",
"tickets": "Open tickets",
"ticketsHint": "CMS and help-center queues",
"imports": "Imports to review",
"importsHint": "Your interrupted jobs or jobs with failed items",
"errors": "Open CMS error groups",
"errorsHint": "From the retained diagnostic history",
"unavailable": "Status unavailable — open to inspect"
},
"cmsErrors": {
"title": "CMS error center",
"storageError": "Error storage could not be read. Check the storage volume and permissions.",
"subtitle": "Server and browser errors from the last 7 days. Browser reports are unverified client observations.",
"emulator": "Emulator errors",
"refresh": "Refresh / clear filters",
"search": "Search errors",
"allSources": "All sources",
"empty": "No captured errors match these filters. Older console logs are not imported.",
"resolve": "Mark resolved",
"reopenHint": "A new occurrence reopens the group automatically.",
"references": "Occurrence references",
"stackHint": "Browser stacks may reference compiled chunks. Use the reference to investigate; request bodies are not stored.",
"previous": "Previous",
"next": "Next",
"searchHint": "Reference, digest, message, route or release",
"source": "Error source",
"pages": "Error pages",
"resolved": "Resolved",
"open": "Open",
"occurrences": "{count} occurrences",
"reference": "Reference",
"release": "Release",
"noStack": "No stack supplied. Use the reference or digest to correlate server logs.",
"truncated": "Showing the latest 1,000 events; filters search this retained window.",
"counts": "{count} occurrences · {groups} groups.",
"retention": "Storage is limited to 10 MB per day; excess events remain in server logs where available.",
"status": "Status",
"allStatuses": "All statuses"
},
"importHistory": {
"summary": "Import history · {count} active",
"empty": "No background imports yet.",
"result": "{done}/{total} imported · {failed} failed",
"progress": "Import progress",
"queued": "Queued",
"running": "Running",
"completed": "Completed",
"interrupted": "Interrupted",
"done": "Imported",
"failed": "Failed",
"pending": "Pending",
"retryFailed": "Retry failed items only",
"retryUnfinished": "Retry unfinished items"
} }
}, },
"mod": { "mod": {
+116 -4
View File
@@ -1052,7 +1052,20 @@
"tools": "Strumenti", "tools": "Strumenti",
"guilds": "Gruppi", "guilds": "Gruppi",
"itemsBase": "Items base", "itemsBase": "Items base",
"studio": "Studio" "studio": "Studio",
"peopleSupport": "Utenti e assistenza",
"catalogFurniture": "Catalogo e furni",
"communityContent": "Community e contenuti",
"staffPermissions": "Staff e accessi",
"favorites": "Preferiti",
"addFavorite": "Aggiungi preferito",
"removeFavorite": "Rimuovi {name} dai preferiti",
"favoriteStorageError": "Preferiti disponibili per questa sessione: memoria del browser non disponibile.",
"cmsErrors": "Errori CMS",
"themeBuilder": "Editor del tema",
"catalogMaintenance": "Manutenzione catalogo",
"openSidebar": "Apri menu",
"closeSidebar": "Chiudi menu"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -1103,7 +1116,13 @@
"saving": "Salvataggio…", "saving": "Salvataggio…",
"deleteArticle": "Elimina articolo", "deleteArticle": "Elimina articolo",
"deleteConfirm": "Questa azione è irreversibile. L'articolo verrà rimosso permanentemente.", "deleteConfirm": "Questa azione è irreversibile. L'articolo verrà rimosso permanentemente.",
"cancel": "Annulla" "cancel": "Annulla",
"status": "Stato",
"statusDraft": "Bozza",
"statusScheduled": "Programmato",
"statusPublished": "Pubblicato",
"publishAt": "Data di pubblicazione",
"publishedAt": "Pubblicato il"
} }
}, },
"events": { "events": {
@@ -1204,7 +1223,8 @@
}, },
"devops": { "devops": {
"noEmulatorErrors": "Nessun errore emulator trovato", "noEmulatorErrors": "Nessun errore emulator trovato",
"noRecentErrors": "Nessun errore recente" "noRecentErrors": "Nessun errore recente",
"cmsErrors": "Centro errori CMS"
}, },
"tags": { "tags": {
"title": "Tag", "title": "Tag",
@@ -2965,7 +2985,8 @@
"errors": "Errori", "errors": "Errori",
"alerts": "Alert", "alerts": "Alert",
"toolsGroup": "Strumenti", "toolsGroup": "Strumenti",
"ticketInbox": "Inbox" "ticketInbox": "Inbox",
"cmsErrors": "Errori CMS"
}, },
"events": "Eventi", "events": "Eventi",
"eventsSubtitle": "Eventi hotel e tipi evento", "eventsSubtitle": "Eventi hotel e tipi evento",
@@ -3221,6 +3242,97 @@
"modNavInbox": "Inbox", "modNavInbox": "Inbox",
"modNavDesk": "Supporto CMS", "modNavDesk": "Supporto CMS",
"modNavHelp": "Help center" "modNavHelp": "Help center"
},
"search": {
"pages": "Pagine e strumenti",
"description": "Trova una pagina di gestione o cerca nei dati dell’hotel.",
"loadError": "Impossibile caricare i risultati. Puoi comunque aprire le pagine elencate.",
"placeholder": "Cerca pagine, utenti, stanze…"
},
"table": {
"search": "Cerca…",
"clearSearch": "Cancella ricerca",
"filters": "Filtri",
"clearAll": "Rimuovi tutti",
"all": "Tutti",
"columns": "Colonne",
"perPage": "{count} / pagina",
"selected": "{count} selezionati",
"results": "{count} risultati",
"empty": "Nessun risultato corrisponde alla ricerca o ai filtri.",
"selectAll": "Seleziona tutte le righe di questa pagina",
"selectRow": "Seleziona riga {id}",
"removeFilter": "Rimuovi filtro: {name}",
"previous": "Pagina precedente",
"next": "Pagina successiva",
"page": "Pagina {page}",
"pageOf": "Pagina {page} di {last}",
"bulkError": "Operazione non riuscita. La selezione è stata mantenuta: verifica il risultato prima di riprovare.",
"confirmBulk": "Eseguire {action} su {count} righe selezionate?"
},
"actions": {
"saved": "Salvato correttamente.",
"error": "Impossibile completare l’operazione.",
"saving": "Salvataggio…",
"saveError": "Salvataggio non riuscito. Le modifiche sono ancora qui: riprova.",
"unsaved": "Modifiche non salvate",
"leaveUnsaved": "Hai modifiche non salvate. Vuoi lasciare la pagina?",
"preview": "Anteprima"
},
"workQueue": {
"title": "Da controllare",
"tickets": "Ticket aperti",
"ticketsHint": "Code del CMS e del centro assistenza",
"imports": "Import da verificare",
"importsHint": "I tuoi processi interrotti o con furni non importati",
"errors": "Gruppi di errori CMS aperti",
"errorsHint": "Nello storico diagnostico conservato",
"unavailable": "Stato non disponibile — apri per verificare"
},
"cmsErrors": {
"title": "Centro errori CMS",
"storageError": "Impossibile leggere gli errori. Verifica volume di archiviazione e permessi.",
"subtitle": "Errori server e browser degli ultimi 7 giorni. Le segnalazioni browser sono osservazioni del client non verificate.",
"emulator": "Errori emulatore",
"refresh": "Aggiorna / azzera filtri",
"search": "Cerca errori",
"allSources": "Tutte le origini",
"empty": "Nessun errore registrato corrisponde ai filtri. I vecchi log della console non vengono importati.",
"resolve": "Segna come risolto",
"reopenHint": "Una nuova occorrenza riapre automaticamente il gruppo.",
"references": "Riferimenti delle occorrenze",
"stackHint": "Gli stack del browser possono riferirsi a file compilati. Usa il riferimento per indagare; i contenuti delle richieste non vengono salvati.",
"previous": "Precedente",
"next": "Successiva",
"searchHint": "Riferimento, digest, messaggio, percorso o versione",
"source": "Origine errore",
"pages": "Pagine errori",
"resolved": "Risolto",
"open": "Aperto",
"occurrences": "{count} occorrenze",
"reference": "Riferimento",
"release": "Versione",
"noStack": "Stack non disponibile. Usa riferimento o digest per cercare nei log del server.",
"truncated": "Sono mostrati gli ultimi 1.000 eventi: i filtri cercano in questa finestra.",
"counts": "{count} occorrenze · {groups} gruppi.",
"retention": "Limite di archiviazione: 10 MB al giorno. Gli eventi eccedenti restano nei log server, dove disponibili.",
"status": "Stato",
"allStatuses": "Tutti gli stati"
},
"importHistory": {
"summary": "Storico importazioni · {count} attive",
"empty": "Nessuna importazione in background.",
"result": "{done}/{total} importati · {failed} falliti",
"progress": "Avanzamento importazione",
"queued": "In coda",
"running": "In corso",
"completed": "Completato",
"interrupted": "Interrotto",
"done": "Importato",
"failed": "Non riuscito",
"pending": "In attesa",
"retryFailed": "Riprova solo i furni falliti",
"retryUnfinished": "Riprendi i furni non completati"
} }
}, },
"radioRequests": { "radioRequests": {