feat(admin): configurable sidebar menu order and visibility
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1127807aaa
commit
0224b34f15
11 files changed
+700
-5
No files matched your search
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
type AdminNavConfig,
|
||||||
|
serializeAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
groupOrder: z.array(z.string()),
|
||||||
|
hiddenGroups: z.array(z.string()),
|
||||||
|
hiddenItems: z.array(z.string()),
|
||||||
|
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveAdminNavConfig = adminAction(
|
||||||
|
{
|
||||||
|
permission: PERMS.SETTINGS_EDIT,
|
||||||
|
schema,
|
||||||
|
rateLimitKey: "admin-nav-config-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const config: AdminNavConfig = {
|
||||||
|
groupOrder: ctx.data.groupOrder,
|
||||||
|
hiddenGroups: ctx.data.hiddenGroups,
|
||||||
|
hiddenItems: ctx.data.hiddenItems,
|
||||||
|
itemOrder: ctx.data.itemOrder,
|
||||||
|
};
|
||||||
|
await siteSettings.update(
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
serializeAdminNavConfig(config),
|
||||||
|
);
|
||||||
|
revalidatePath("/admin", "layout");
|
||||||
|
revalidatePath("/admin/menu");
|
||||||
|
return actionOk({ saved: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -11,6 +11,10 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
|||||||
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 { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -86,6 +90,10 @@ async function Sidebar({
|
|||||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navConfig = parseAdminNavConfig(
|
||||||
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-admin
|
data-admin
|
||||||
@@ -115,6 +123,7 @@ async function Sidebar({
|
|||||||
<AdminSidebarNav
|
<AdminSidebarNav
|
||||||
allowedPermissions={allowedPermissions}
|
allowedPermissions={allowedPermissions}
|
||||||
isSuperAdmin={isSuperAdmin}
|
isSuperAdmin={isSuperAdmin}
|
||||||
|
navConfig={navConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { saveAdminNavConfig } from "@/actions/admin-nav-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_PINNED_HREFS,
|
||||||
|
type AdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type EditableNavGroup = {
|
||||||
|
labelKey: string;
|
||||||
|
items: { href: string; labelKey: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function moveIndex<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
if (to < 0 || to >= arr.length || from === to) return arr;
|
||||||
|
const next = [...arr];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
if (item === undefined) return arr;
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(
|
||||||
|
groups: EditableNavGroup[],
|
||||||
|
hiddenGroups: Set<string>,
|
||||||
|
hiddenItems: Set<string>,
|
||||||
|
): Required<AdminNavConfig> {
|
||||||
|
return {
|
||||||
|
groupOrder: groups.map((g) => g.labelKey),
|
||||||
|
hiddenGroups: [...hiddenGroups],
|
||||||
|
hiddenItems: [...hiddenItems].filter((h) => !ADMIN_NAV_PINNED_HREFS.has(h)),
|
||||||
|
itemOrder: Object.fromEntries(
|
||||||
|
groups.map((g) => [g.labelKey, g.items.map((i) => i.href)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminMenuEditor({
|
||||||
|
catalog,
|
||||||
|
initialConfig,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
catalog: EditableNavGroup[];
|
||||||
|
initialConfig: AdminNavConfig;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
|
const t = useTranslations("pages.admin.menu");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const initialGroups = useMemo(() => {
|
||||||
|
const order = initialConfig.groupOrder ?? [];
|
||||||
|
const byKey = new Map(catalog.map((g) => [g.labelKey, g]));
|
||||||
|
const ordered: EditableNavGroup[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const key of order) {
|
||||||
|
const g = byKey.get(key);
|
||||||
|
if (!g || seen.has(key)) continue;
|
||||||
|
const itemOrder = initialConfig.itemOrder?.[key] ?? [];
|
||||||
|
const itemsByHref = new Map(g.items.map((i) => [i.href, i]));
|
||||||
|
const items = [
|
||||||
|
...itemOrder
|
||||||
|
.map((href) => itemsByHref.get(href))
|
||||||
|
.filter((i): i is NonNullable<typeof i> => Boolean(i)),
|
||||||
|
...g.items.filter((i) => !itemOrder.includes(i.href)),
|
||||||
|
];
|
||||||
|
ordered.push({ labelKey: g.labelKey, items });
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
for (const g of catalog) {
|
||||||
|
if (seen.has(g.labelKey)) continue;
|
||||||
|
ordered.push({ ...g, items: [...g.items] });
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [catalog, initialConfig]);
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState(initialGroups);
|
||||||
|
const [hiddenGroups, setHiddenGroups] = useState(
|
||||||
|
() => new Set(initialConfig.hiddenGroups ?? []),
|
||||||
|
);
|
||||||
|
const [hiddenItems, setHiddenItems] = useState(
|
||||||
|
() => new Set(initialConfig.hiddenItems ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setGroups(catalog.map((g) => ({ ...g, items: [...g.items] })));
|
||||||
|
setHiddenGroups(new Set());
|
||||||
|
setHiddenItems(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const config = buildConfig(groups, hiddenGroups, hiddenItems);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await saveAdminNavConfig(config);
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(t("saved"));
|
||||||
|
} else {
|
||||||
|
toast.error(res.error ?? t("saveFailed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canEdit ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={save} disabled={pending}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{pending ? t("saving") : t("save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={reset}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
{t("reset")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground w-full sm:w-auto sm:ml-auto">
|
||||||
|
{t("hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const groupHidden = hiddenGroups.has(group.labelKey);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={group.labelKey}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3",
|
||||||
|
groupHidden && "opacity-55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-sm uppercase tracking-wide flex-1 min-w-0">
|
||||||
|
{tNav(group.labelKey)}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={gi === 0}
|
||||||
|
aria-label={t("moveUp")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) => moveIndex(prev, gi, gi - 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={gi === groups.length - 1}
|
||||||
|
aria-label={t("moveDown")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) => moveIndex(prev, gi, gi + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={groupHidden ? t("showGroup") : t("hideGroup")}
|
||||||
|
onClick={() =>
|
||||||
|
setHiddenGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(group.labelKey))
|
||||||
|
next.delete(group.labelKey);
|
||||||
|
else next.add(group.labelKey);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{groupHidden ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{group.items.map((item, ii) => {
|
||||||
|
const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href);
|
||||||
|
const itemHidden = !pinned && hiddenItems.has(item.href);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||||
|
"bg-[var(--admin-canvas)]/60",
|
||||||
|
itemHidden && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 truncate">
|
||||||
|
{tNav(item.labelKey)}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{item.href}
|
||||||
|
</span>
|
||||||
|
{pinned && (
|
||||||
|
<span className="ml-2 text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("pinned")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={ii === 0}
|
||||||
|
aria-label={t("moveUp")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g, idx) =>
|
||||||
|
idx === gi
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
items: moveIndex(g.items, ii, ii - 1),
|
||||||
|
}
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={ii === group.items.length - 1}
|
||||||
|
aria-label={t("moveDown")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g, idx) =>
|
||||||
|
idx === gi
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
items: moveIndex(g.items, ii, ii + 1),
|
||||||
|
}
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={pinned}
|
||||||
|
aria-label={
|
||||||
|
itemHidden ? t("showItem") : t("hideItem")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (pinned) return;
|
||||||
|
setHiddenItems((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.href)) next.delete(item.href);
|
||||||
|
else next.add(item.href);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemHidden || pinned ? (
|
||||||
|
itemHidden ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 opacity-40" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ListOrdered } from "lucide-react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
|
import { ADMIN_NAV_GROUPS } 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";
|
||||||
|
import { AdminMenuEditor } from "./admin-menu-editor";
|
||||||
|
|
||||||
|
export const metadata = { title: "Admin menu" };
|
||||||
|
|
||||||
|
export default async function AdminMenuPage() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
const canEdit = canAccess(
|
||||||
|
permissions,
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
session.user.rank,
|
||||||
|
);
|
||||||
|
const t = await getTranslations("pages.admin.menu");
|
||||||
|
|
||||||
|
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
||||||
|
labelKey: g.labelKey,
|
||||||
|
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
||||||
|
}));
|
||||||
|
const initialConfig = parseAdminNavConfig(
|
||||||
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageShell
|
||||||
|
icon={ListOrdered}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
>
|
||||||
|
<AdminMenuEditor
|
||||||
|
catalog={catalog}
|
||||||
|
initialConfig={initialConfig}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</AdminPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
type AdminNavItem,
|
type AdminNavItem,
|
||||||
navItemIsAllowed,
|
navItemIsAllowed,
|
||||||
} from "@/lib/admin-nav";
|
} from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
type AdminNavConfig,
|
||||||
|
applyAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STORAGE_KEY = "admin-nav-collapsed";
|
const STORAGE_KEY = "admin-nav-collapsed";
|
||||||
@@ -59,10 +63,13 @@ function persistCollapsed(next: Record<string, boolean>) {
|
|||||||
export function AdminSidebarNav({
|
export function AdminSidebarNav({
|
||||||
allowedPermissions = [],
|
allowedPermissions = [],
|
||||||
isSuperAdmin = false,
|
isSuperAdmin = false,
|
||||||
|
navConfig = {},
|
||||||
}: {
|
}: {
|
||||||
/** 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;
|
||||||
|
/** Owner overlay: hide + reorder groups/items (from website_settings). */
|
||||||
|
navConfig?: AdminNavConfig;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.nav");
|
const t = useTranslations("pages.admin.nav");
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
@@ -79,11 +86,13 @@ export function AdminSidebarNav({
|
|||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
has: (slug: string) => allowed.has(slug),
|
has: (slug: string) => allowed.has(slug),
|
||||||
};
|
};
|
||||||
return ADMIN_NAV_GROUPS.map((group) => ({
|
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
|
||||||
...group,
|
.map((group) => ({
|
||||||
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
...group,
|
||||||
})).filter((group) => group.items.length > 0);
|
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
||||||
}, [allowed, isSuperAdmin]);
|
}))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
}, [allowed, isSuperAdmin, navConfig]);
|
||||||
|
|
||||||
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { LayoutDashboard, Newspaper, Settings } from "lucide-react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_PINNED_HREFS,
|
||||||
|
applyAdminNavConfig,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
serializeAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
|
||||||
|
const catalog: AdminNavGroup[] = [
|
||||||
|
{
|
||||||
|
labelKey: "overview",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
items: [
|
||||||
|
{ href: "/admin", labelKey: "dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/admin/menu", labelKey: "menu", icon: Settings },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "content",
|
||||||
|
icon: Newspaper,
|
||||||
|
items: [
|
||||||
|
{ href: "/admin/articles", labelKey: "articles", icon: Newspaper },
|
||||||
|
{ href: "/admin/photos", labelKey: "photos", icon: Newspaper },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "system",
|
||||||
|
icon: Settings,
|
||||||
|
items: [{ href: "/admin/settings", labelKey: "settings", icon: Settings }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("admin-nav-config", () => {
|
||||||
|
it("parses empty / invalid as empty config", () => {
|
||||||
|
expect(parseAdminNavConfig(null)).toEqual({});
|
||||||
|
expect(parseAdminNavConfig("")).toEqual({});
|
||||||
|
expect(parseAdminNavConfig("not-json")).toEqual({});
|
||||||
|
expect(parseAdminNavConfig("[]")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders groups and items", () => {
|
||||||
|
const applied = applyAdminNavConfig(catalog, {
|
||||||
|
groupOrder: ["system", "content", "overview"],
|
||||||
|
itemOrder: {
|
||||||
|
content: ["/admin/photos", "/admin/articles"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(applied.map((g) => g.labelKey)).toEqual([
|
||||||
|
"system",
|
||||||
|
"content",
|
||||||
|
"overview",
|
||||||
|
]);
|
||||||
|
expect(applied[1]?.items.map((i) => i.href)).toEqual([
|
||||||
|
"/admin/photos",
|
||||||
|
"/admin/articles",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides groups and items but keeps pinned hrefs", () => {
|
||||||
|
const applied = applyAdminNavConfig(catalog, {
|
||||||
|
hiddenGroups: ["system"],
|
||||||
|
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
||||||
|
});
|
||||||
|
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
||||||
|
expect(applied[0]?.items.map((i) => i.href)).toEqual([
|
||||||
|
"/admin",
|
||||||
|
"/admin/menu",
|
||||||
|
]);
|
||||||
|
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
||||||
|
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips serialize → parse", () => {
|
||||||
|
const raw = serializeAdminNavConfig({
|
||||||
|
groupOrder: ["content"],
|
||||||
|
hiddenGroups: ["system"],
|
||||||
|
hiddenItems: ["/admin/photos", "/admin"],
|
||||||
|
itemOrder: { content: ["/admin/articles"] },
|
||||||
|
});
|
||||||
|
const parsed = parseAdminNavConfig(raw);
|
||||||
|
expect(parsed.groupOrder).toEqual(["content"]);
|
||||||
|
expect(parsed.hiddenGroups).toEqual(["system"]);
|
||||||
|
expect(parsed.hiddenItems).toEqual(["/admin/photos"]);
|
||||||
|
expect(parsed.itemOrder).toEqual({ content: ["/admin/articles"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||||
|
|
||||||
|
/** website_settings key storing JSON overlay for the admin sidebar. */
|
||||||
|
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
||||||
|
|
||||||
|
/** Hrefs that cannot be hidden (dashboard + menu editor). */
|
||||||
|
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
|
||||||
|
|
||||||
|
export type AdminNavConfig = {
|
||||||
|
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
||||||
|
groupOrder?: string[];
|
||||||
|
/** Group labelKeys fully hidden from the sidebar. */
|
||||||
|
hiddenGroups?: string[];
|
||||||
|
/** Item hrefs hidden from the sidebar (except pinned). */
|
||||||
|
hiddenItems?: string[];
|
||||||
|
/** Per-group item href order. Missing items append in catalog order. */
|
||||||
|
itemOrder?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isStringArray(v: unknown): v is string[] {
|
||||||
|
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse stored JSON; invalid / empty → empty config. */
|
||||||
|
export function parseAdminNavConfig(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): AdminNavConfig {
|
||||||
|
if (!raw?.trim()) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const o = parsed as Record<string, unknown>;
|
||||||
|
const config: AdminNavConfig = {};
|
||||||
|
if (isStringArray(o.groupOrder)) config.groupOrder = o.groupOrder;
|
||||||
|
if (isStringArray(o.hiddenGroups)) config.hiddenGroups = o.hiddenGroups;
|
||||||
|
if (isStringArray(o.hiddenItems)) config.hiddenItems = o.hiddenItems;
|
||||||
|
if (
|
||||||
|
o.itemOrder &&
|
||||||
|
typeof o.itemOrder === "object" &&
|
||||||
|
!Array.isArray(o.itemOrder)
|
||||||
|
) {
|
||||||
|
const itemOrder: Record<string, string[]> = {};
|
||||||
|
for (const [k, v] of Object.entries(
|
||||||
|
o.itemOrder as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (isStringArray(v)) itemOrder[k] = v;
|
||||||
|
}
|
||||||
|
config.itemOrder = itemOrder;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderByKeys<T>(
|
||||||
|
items: T[],
|
||||||
|
order: string[] | undefined,
|
||||||
|
keyOf: (item: T) => string,
|
||||||
|
): T[] {
|
||||||
|
if (!order?.length) return items;
|
||||||
|
const byKey = new Map(items.map((item) => [keyOf(item), item]));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const key of order) {
|
||||||
|
const hit = byKey.get(key);
|
||||||
|
if (!hit || seen.has(key)) continue;
|
||||||
|
out.push(hit);
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keyOf(item);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply owner overlay (hide + reorder) on top of the code catalog.
|
||||||
|
* Does not apply ACL — callers filter permissions afterwards.
|
||||||
|
*/
|
||||||
|
export function applyAdminNavConfig(
|
||||||
|
groups: AdminNavGroup[],
|
||||||
|
config: AdminNavConfig,
|
||||||
|
): AdminNavGroup[] {
|
||||||
|
const hiddenGroups = new Set(config.hiddenGroups ?? []);
|
||||||
|
const hiddenItems = new Set(config.hiddenItems ?? []);
|
||||||
|
|
||||||
|
const mapped = groups
|
||||||
|
.filter((g) => !hiddenGroups.has(g.labelKey))
|
||||||
|
.map((group) => {
|
||||||
|
const items = group.items.filter((item) => {
|
||||||
|
if (ADMIN_NAV_PINNED_HREFS.has(item.href)) return true;
|
||||||
|
return !hiddenItems.has(item.href);
|
||||||
|
});
|
||||||
|
const orderedItems = orderByKeys(
|
||||||
|
items,
|
||||||
|
config.itemOrder?.[group.labelKey],
|
||||||
|
(item) => item.href,
|
||||||
|
);
|
||||||
|
return { ...group, items: orderedItems };
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
|
return orderByKeys(mapped, config.groupOrder, (g) => g.labelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAdminNavConfig(config: AdminNavConfig): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
groupOrder: config.groupOrder ?? [],
|
||||||
|
hiddenGroups: config.hiddenGroups ?? [],
|
||||||
|
hiddenItems: (config.hiddenItems ?? []).filter(
|
||||||
|
(href) => !ADMIN_NAV_PINNED_HREFS.has(href),
|
||||||
|
),
|
||||||
|
itemOrder: config.itemOrder ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Languages,
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
ListOrdered,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Monitor,
|
Monitor,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
@@ -528,6 +529,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
icon: Cog,
|
icon: Cog,
|
||||||
permission: PERMS.SETTINGS_VIEW,
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/menu",
|
||||||
|
labelKey: "adminMenu",
|
||||||
|
icon: ListOrdered,
|
||||||
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/theme",
|
href: "/admin/theme",
|
||||||
labelKey: "theme",
|
labelKey: "theme",
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ describe("staff smoke contract", () => {
|
|||||||
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ships dynamic admin menu overlay", () => {
|
||||||
|
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
||||||
|
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
|
||||||
|
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||||
|
"/admin/menu",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
||||||
|
).toContain("applyAdminNavConfig");
|
||||||
|
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
||||||
|
"ADMIN_NAV_CONFIG_KEY",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps force-dynamic only on admin/mod layouts", () => {
|
it("keeps force-dynamic only on admin/mod layouts", () => {
|
||||||
const hits: string[] = [];
|
const hits: string[] = [];
|
||||||
function walk(dir: string) {
|
function walk(dir: string) {
|
||||||
|
|||||||
@@ -1046,6 +1046,7 @@
|
|||||||
"translations": "Translations",
|
"translations": "Translations",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"adminMenu": "Admin menu",
|
||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
@@ -2971,6 +2972,24 @@
|
|||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Admin menu",
|
||||||
|
"subtitle": "Reorder and hide sidebar groups and links. Routes and permissions stay in code.",
|
||||||
|
"save": "Save menu",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Admin menu saved.",
|
||||||
|
"saveFailed": "Could not save admin menu.",
|
||||||
|
"reset": "Reset to default",
|
||||||
|
"viewOnly": "You can view the menu layout but need settings edit permission to change it.",
|
||||||
|
"hint": "Dashboard and this page stay visible. ACL still filters what each rank sees.",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"hideGroup": "Hide group",
|
||||||
|
"showGroup": "Show group",
|
||||||
|
"hideItem": "Hide item",
|
||||||
|
"showItem": "Show item",
|
||||||
|
"pinned": "Pinned"
|
||||||
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.",
|
"loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.",
|
||||||
"totalUsers": "Total users",
|
"totalUsers": "Total users",
|
||||||
|
|||||||
@@ -1010,6 +1010,7 @@
|
|||||||
"translations": "Traduzioni",
|
"translations": "Traduzioni",
|
||||||
"import": "Importa",
|
"import": "Importa",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
|
"adminMenu": "Menu admin",
|
||||||
"backToSite": "Torna al sito",
|
"backToSite": "Torna al sito",
|
||||||
"expandAll": "Apri tutte",
|
"expandAll": "Apri tutte",
|
||||||
"collapseAll": "Chiudi tutte",
|
"collapseAll": "Chiudi tutte",
|
||||||
@@ -2853,6 +2854,24 @@
|
|||||||
"cancel": "Annulla"
|
"cancel": "Annulla"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Menu admin",
|
||||||
|
"subtitle": "Riordina e nascondi gruppi e voci della sidebar. Route e permessi restano nel codice.",
|
||||||
|
"save": "Salva menu",
|
||||||
|
"saving": "Salvataggio…",
|
||||||
|
"saved": "Menu admin salvato.",
|
||||||
|
"saveFailed": "Impossibile salvare il menu admin.",
|
||||||
|
"reset": "Ripristina default",
|
||||||
|
"viewOnly": "Puoi vedere il layout del menu ma serve il permesso di modifica impostazioni.",
|
||||||
|
"hint": "Dashboard e questa pagina restano visibili. L'ACL continua a filtrare per grado.",
|
||||||
|
"moveUp": "Sposta su",
|
||||||
|
"moveDown": "Sposta giù",
|
||||||
|
"hideGroup": "Nascondi gruppo",
|
||||||
|
"showGroup": "Mostra gruppo",
|
||||||
|
"hideItem": "Nascondi voce",
|
||||||
|
"showItem": "Mostra voce",
|
||||||
|
"pinned": "Fisso"
|
||||||
|
},
|
||||||
"favicon": {
|
"favicon": {
|
||||||
"title": "Favicon",
|
"title": "Favicon",
|
||||||
"subtitle": "Upload and manage the site favicon",
|
"subtitle": "Upload and manage the site favicon",
|
||||||
|
|||||||
Reference in new issue
Block a user