diff --git a/src/actions/admin-nav-menu.ts b/src/actions/admin-nav-menu.ts
new file mode 100644
index 00000000..7460a81f
--- /dev/null
+++ b/src/actions/admin-nav-menu.ts
@@ -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 });
+ },
+);
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index bc5806d0..41762864 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -11,6 +11,10 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
+import {
+ ADMIN_NAV_CONFIG_KEY,
+ parseAdminNavConfig,
+} from "@/lib/admin-nav-config";
import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -86,6 +90,10 @@ async function Sidebar({
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
});
+ const navConfig = parseAdminNavConfig(
+ await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
+ );
+
return (
diff --git a/src/app/admin/menu/admin-menu-editor.tsx b/src/app/admin/menu/admin-menu-editor.tsx
new file mode 100644
index 00000000..0d57dc53
--- /dev/null
+++ b/src/app/admin/menu/admin-menu-editor.tsx
@@ -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
(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
new file mode 100644
index 00000000..910fea23
--- /dev/null
+++ b/src/app/admin/menu/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/components/admin/admin-sidebar-nav.tsx b/src/components/admin/admin-sidebar-nav.tsx
index c0ac0125..0fda05e0 100644
--- a/src/components/admin/admin-sidebar-nav.tsx
+++ b/src/components/admin/admin-sidebar-nav.tsx
@@ -10,6 +10,10 @@ import {
type AdminNavItem,
navItemIsAllowed,
} from "@/lib/admin-nav";
+import {
+ type AdminNavConfig,
+ applyAdminNavConfig,
+} from "@/lib/admin-nav-config";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "admin-nav-collapsed";
@@ -59,10 +63,13 @@ function persistCollapsed(next: Record) {
export function AdminSidebarNav({
allowedPermissions = [],
isSuperAdmin = false,
+ navConfig = {},
}: {
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
allowedPermissions?: string[];
isSuperAdmin?: boolean;
+ /** Owner overlay: hide + reorder groups/items (from website_settings). */
+ navConfig?: AdminNavConfig;
}) {
const t = useTranslations("pages.admin.nav");
const pathname = usePathname() ?? "";
@@ -79,11 +86,13 @@ export function AdminSidebarNav({
isSuperAdmin,
has: (slug: string) => allowed.has(slug),
};
- return ADMIN_NAV_GROUPS.map((group) => ({
- ...group,
- items: group.items.filter((item) => navItemIsAllowed(item, access)),
- })).filter((group) => group.items.length > 0);
- }, [allowed, isSuperAdmin]);
+ 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 allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
diff --git a/src/lib/admin-nav-config.test.ts b/src/lib/admin-nav-config.test.ts
new file mode 100644
index 00000000..4956e9ae
--- /dev/null
+++ b/src/lib/admin-nav-config.test.ts
@@ -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"] });
+ });
+});
diff --git a/src/lib/admin-nav-config.ts b/src/lib/admin-nav-config.ts
new file mode 100644
index 00000000..adc0cc8f
--- /dev/null
+++ b/src/lib/admin-nav-config.ts
@@ -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;
+};
+
+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;
+ 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 = {};
+ for (const [k, v] of Object.entries(
+ o.itemOrder as Record,
+ )) {
+ if (isStringArray(v)) itemOrder[k] = v;
+ }
+ config.itemOrder = itemOrder;
+ }
+ return config;
+ } catch {
+ return {};
+ }
+}
+
+function orderByKeys(
+ 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();
+ 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 ?? {},
+ });
+}
diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts
index c99b9594..478e53ff 100644
--- a/src/lib/admin-nav.ts
+++ b/src/lib/admin-nav.ts
@@ -18,6 +18,7 @@ import {
KeyRound,
Languages,
LayoutDashboard,
+ ListOrdered,
Megaphone,
Monitor,
Newspaper,
@@ -528,6 +529,12 @@ 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 43020166..07112576 100644
--- a/src/lib/staff-smoke-contract.test.ts
+++ b/src/lib/staff-smoke-contract.test.ts
@@ -58,6 +58,20 @@ describe("staff smoke contract", () => {
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", () => {
const hits: string[] = [];
function walk(dir: string) {
diff --git a/src/messages/en.json b/src/messages/en.json
index 964c04e9..5708c55c 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -1046,6 +1046,7 @@
"translations": "Translations",
"import": "Import",
"settings": "Settings",
+ "adminMenu": "Admin menu",
"backToSite": "Back to site",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
@@ -2971,6 +2972,24 @@
"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": {
"loadError": "Some analytics queries failed. Numbers below may be incomplete — check the database connection and emulator tables.",
"totalUsers": "Total users",
diff --git a/src/messages/it.json b/src/messages/it.json
index 7c15d3ca..483bc1c4 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -1010,6 +1010,7 @@
"translations": "Traduzioni",
"import": "Importa",
"settings": "Impostazioni",
+ "adminMenu": "Menu admin",
"backToSite": "Torna al sito",
"expandAll": "Apri tutte",
"collapseAll": "Chiudi tutte",
@@ -2853,6 +2854,24 @@
"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": {
"title": "Favicon",
"subtitle": "Upload and manage the site favicon",