feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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([]),
|
||||||
|
);
|
||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
Reference in new issue
Block a user