diff --git a/src/actions/admin-nav-menu.ts b/src/actions/admin-nav-menu.ts deleted file mode 100644 index 7460a81f..00000000 --- a/src/actions/admin-nav-menu.ts +++ /dev/null @@ -1,43 +0,0 @@ -"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 }); - }, -); diff --git a/src/app/admin/menu/admin-menu-editor.tsx b/src/app/admin/menu/admin-menu-editor.tsx deleted file mode 100644 index 0d57dc53..00000000 --- a/src/app/admin/menu/admin-menu-editor.tsx +++ /dev/null @@ -1,318 +0,0 @@ -"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(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, - hiddenItems: Set, -): Required { - 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(); - 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 => 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 ( -
-
- {canEdit ? ( - <> - - - - ) : ( -

{t("viewOnly")}

- )} -

- {t("hint")} -

-
- -
- {groups.map((group, gi) => { - const groupHidden = hiddenGroups.has(group.labelKey); - return ( -
-
- - {tNav(group.labelKey)} - - {canEdit && ( - <> - - - - - )} -
- -
    - {group.items.map((item, ii) => { - const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href); - const itemHidden = !pinned && hiddenItems.has(item.href); - return ( -
  • - - {tNav(item.labelKey)} - - {item.href} - - {pinned && ( - - {t("pinned")} - - )} - - {canEdit && ( - <> - - - - - )} -
  • - ); - })} -
-
- ); - })} -
-
- ); -} diff --git a/src/app/admin/menu/page.tsx b/src/app/admin/menu/page.tsx deleted file mode 100644 index 910fea23..00000000 --- a/src/app/admin/menu/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 ( - - - - ); -} diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index f93db2e4..f3ad7eb0 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -612,8 +612,8 @@ describe("housekeeping foundation completion contracts", () => { HOUSEKEEPING_MIGRATION_MATRIX, ); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139); - expect(discovered).toHaveLength(139); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138); + expect(discovered).toHaveLength(138); expect(issues).toEqual([]); }); }); diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts index 6482d4f5..8138cacc 100644 --- a/src/features/housekeeping/migration/discover-legacy-pages.test.ts +++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts @@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => { it("discovers the exact legacy administration inventory", () => { const pages = discoverLegacyPages(); - expect(pages).toHaveLength(139); + expect(pages).toHaveLength(138); expect(pages).toContainEqual({ surface: "admin", legacyPath: "/admin/users/:id/edit", diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts index 3db71421..1badc4e0 100644 --- a/src/features/housekeeping/migration/matrix.test.ts +++ b/src/features/housekeeping/migration/matrix.test.ts @@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix"; import { validateMigrationEntries } from "./validate-matrix"; describe("HOUSEKEEPING_MIGRATION_MATRIX", () => { - it("covers all 139 legacy pages exactly once", () => { + it("covers all 138 legacy pages exactly once", () => { const discovered = discoverLegacyPages(); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138); expect( validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX), ).toEqual([]); diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts index 76029d30..56a0e425 100644 --- a/src/features/housekeeping/migration/system.test.ts +++ b/src/features/housekeeping/migration/system.test.ts @@ -13,14 +13,13 @@ const SYSTEM_PREFIXES = [ "/admin/housekeeping", "/admin/logs", "/admin/maintenance", - "/admin/menu", "/admin/permissions", "/admin/settings", ] as const; describe("systemMigrationEntries", () => { - it("covers all 20 System pages exactly once", () => { - expect(systemMigrationEntries).toHaveLength(20); + it("covers all 19 System pages exactly once", () => { + expect(systemMigrationEntries).toHaveLength(19); expect( validateMigrationEntries( ownedLegacyPages(SYSTEM_PREFIXES), @@ -37,11 +36,6 @@ describe("systemMigrationEntries", () => { decision: "REMOVE", targetPath: null, }), - expect.objectContaining({ - legacyPath: "/admin/menu", - decision: "REMOVE", - targetPath: null, - }), ]), ); }); diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts index 548bba6d..f670e7c8 100644 --- a/src/features/housekeeping/migration/system.ts +++ b/src/features/housekeeping/migration/system.ts @@ -346,28 +346,6 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [ accessibility: "PARTIAL", notes: ["Maintenance changes global login availability and splash content"], }), - plannedSystemEntry({ - surface: "admin", - legacyPath: "/admin/menu", - sourceFile: "src/app/admin/menu/page.tsx", - targetPath: null, - decision: "REMOVE", - capabilities: { - read: [PERMS.SETTINGS_VIEW], - mutate: [PERMS.SETTINGS_EDIT], - }, - dependencies: { - queries: ["ADMIN_NAV_GROUPS", "siteSettings.get"], - mutations: ["saveAdminNavConfig", "siteSettings.update"], - }, - auditRequirement: "PRIVILEGED_MUTATION", - localization: "PARTIAL", - accessibility: "PARTIAL", - requiredTests: ["integration", "e2e"], - notes: [ - "Remove the manual legacy menu editor because foundation manifests own navigation", - ], - }), plannedSystemEntry({ surface: "admin", legacyPath: "/admin/permissions", diff --git a/src/lib/admin-nav-config.test.ts b/src/lib/admin-nav-config.test.ts index 4956e9ae..9f0a04c2 100644 --- a/src/lib/admin-nav-config.test.ts +++ b/src/lib/admin-nav-config.test.ts @@ -64,10 +64,7 @@ describe("admin-nav-config", () => { 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[0]?.items.map((i) => i.href)).toEqual(["/admin"]); expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]); expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true); }); diff --git a/src/lib/admin-nav-config.ts b/src/lib/admin-nav-config.ts index adc0cc8f..7671c83d 100644 --- a/src/lib/admin-nav-config.ts +++ b/src/lib/admin-nav-config.ts @@ -3,8 +3,8 @@ 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"]); +/** Hrefs that cannot be hidden (dashboard). */ +export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin"]); export type AdminNavConfig = { /** Group labelKeys in display order. Missing groups append in catalog order. */ diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index 97f5fbc8..10b8dfc4 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -16,7 +16,6 @@ import { KeyRound, Languages, LayoutDashboard, - ListOrdered, type LucideIcon, Megaphone, Monitor, @@ -541,12 +540,6 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [ icon: Cog, permission: PERMS.SETTINGS_VIEW, }, - { - href: "/admin/menu", - labelKey: "adminMenu", - icon: ListOrdered, - permission: PERMS.SETTINGS_VIEW, - }, { href: "/admin/theme", labelKey: "theme", diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index c86e0381..3e8fc845 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -75,10 +75,10 @@ describe("staff smoke contract", () => { expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true); }); - it("ships dynamic admin menu overlay", () => { + it("keeps sidebar configuration without exposing the retired menu editor", () => { 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( + expect(existsSync("src/app/admin/menu/page.tsx")).toBe(false); + expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain( "/admin/menu", ); expect(