Reorganize admin nav by feature and soften light mode.
Sidebar lists one entry per feature; keep hub tabs only for sibling views (events, tickets, ranks/permissions, moderation, radio, analytics, devops). Soft cool-gray light canvas/surface replaces harsh white. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
022fbfa223
commit
dd806d2527
9 files changed
+344
-267
No files matched your search
@@ -116,6 +116,7 @@ async function Sidebar({
|
|||||||
key={`${group.labelKey}-${item.href}`}
|
key={`${group.labelKey}-${item.href}`}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
matchPrefixes={item.matchPrefixes}
|
matchPrefixes={item.matchPrefixes}
|
||||||
|
matchExcludePrefixes={item.matchExcludePrefixes}
|
||||||
icon={<ItemIcon size={16} />}
|
icon={<ItemIcon size={16} />}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function AdminHubChrome({ children }: { children: ReactNode }) {
|
|||||||
icon={hub.icon}
|
icon={hub.icon}
|
||||||
title={t(hub.titleKey)}
|
title={t(hub.titleKey)}
|
||||||
subtitle={t(hub.subtitleKey)}
|
subtitle={t(hub.subtitleKey)}
|
||||||
tabs={<AdminSectionTabs tabs={tabs} />}
|
tabs={tabs.length > 1 ? <AdminSectionTabs tabs={tabs} /> : undefined}
|
||||||
className="admin-hub-panel"
|
className="admin-hub-panel"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -13,18 +13,26 @@ export function AdminNavLink({
|
|||||||
href,
|
href,
|
||||||
icon,
|
icon,
|
||||||
matchPrefixes,
|
matchPrefixes,
|
||||||
|
matchExcludePrefixes,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
matchPrefixes?: string[];
|
matchPrefixes?: string[];
|
||||||
|
matchExcludePrefixes?: string[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
const { close } = useAdminMobile();
|
const { close } = useAdminMobile();
|
||||||
|
|
||||||
|
const excluded = matchExcludePrefixes?.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||||
|
);
|
||||||
|
|
||||||
let active: boolean;
|
let active: boolean;
|
||||||
if (href === "/admin") {
|
if (excluded) {
|
||||||
|
active = false;
|
||||||
|
} else if (href === "/admin") {
|
||||||
active = pathname === "/admin";
|
active = pathname === "/admin";
|
||||||
} else if (matchPrefixes?.length) {
|
} else if (matchPrefixes?.length) {
|
||||||
active = matchPrefixes.some(
|
active = matchPrefixes.some(
|
||||||
|
|||||||
+242
-245
@@ -1,19 +1,42 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
BadgeCheck,
|
||||||
Ban,
|
Ban,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarDays,
|
||||||
|
ClipboardList,
|
||||||
Cog,
|
Cog,
|
||||||
Compass,
|
Compass,
|
||||||
|
FileText,
|
||||||
|
Filter,
|
||||||
Gavel,
|
Gavel,
|
||||||
|
HelpCircle,
|
||||||
|
Image,
|
||||||
|
Import,
|
||||||
|
KeyRound,
|
||||||
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Megaphone,
|
||||||
Monitor,
|
Monitor,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
|
Package,
|
||||||
Radio,
|
Radio,
|
||||||
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Sparkles,
|
||||||
Store,
|
Store,
|
||||||
|
Tags,
|
||||||
|
Terminal,
|
||||||
|
Ticket,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
|
Volume2,
|
||||||
|
Vote,
|
||||||
|
Wifi,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -43,6 +66,8 @@ export interface AdminNavItem {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
/** When set, sidebar item is active if pathname matches any prefix. */
|
/** When set, sidebar item is active if pathname matches any prefix. */
|
||||||
matchPrefixes?: string[];
|
matchPrefixes?: string[];
|
||||||
|
/** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */
|
||||||
|
matchExcludePrefixes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminNavGroup {
|
export interface AdminNavGroup {
|
||||||
@@ -51,65 +76,29 @@ export interface AdminNavGroup {
|
|||||||
items: AdminNavItem[];
|
items: AdminNavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hubs with real sibling tabs only. Standalone features have no hub chrome
|
||||||
|
* (sidebar links go straight to the page).
|
||||||
|
*/
|
||||||
export const ADMIN_HUBS: AdminHubDefinition[] = [
|
export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "news-media",
|
id: "events",
|
||||||
titleKey: "newsMedia",
|
titleKey: "events",
|
||||||
subtitleKey: "newsMediaSubtitle",
|
subtitleKey: "eventsSubtitle",
|
||||||
icon: Newspaper,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/articles",
|
|
||||||
"/admin/photos",
|
|
||||||
"/admin/banners",
|
|
||||||
"/admin/ads",
|
|
||||||
"/admin/media",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{
|
|
||||||
href: "/admin/articles",
|
|
||||||
labelKey: "articles",
|
|
||||||
match: ["/admin/articles"],
|
|
||||||
},
|
|
||||||
{ href: "/admin/photos", labelKey: "photos" },
|
|
||||||
{ href: "/admin/banners", labelKey: "banners" },
|
|
||||||
{ href: "/admin/ads", labelKey: "ads", match: ["/admin/ads"] },
|
|
||||||
{ href: "/admin/media", labelKey: "media" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "site-content",
|
|
||||||
titleKey: "siteContent",
|
|
||||||
subtitleKey: "siteContentSubtitle",
|
|
||||||
icon: Compass,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/navigation",
|
|
||||||
"/admin/help-questions",
|
|
||||||
"/admin/writeable-boxes",
|
|
||||||
"/admin/tags",
|
|
||||||
"/admin/prefixes",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/navigation", labelKey: "navigator" },
|
|
||||||
{
|
|
||||||
href: "/admin/help-questions",
|
|
||||||
labelKey: "help",
|
|
||||||
match: ["/admin/help-questions"],
|
|
||||||
},
|
|
||||||
{ href: "/admin/writeable-boxes", labelKey: "boxes" },
|
|
||||||
{ href: "/admin/tags", labelKey: "tags" },
|
|
||||||
{ href: "/admin/prefixes", labelKey: "prefixes" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "engagement",
|
|
||||||
titleKey: "engagement",
|
|
||||||
subtitleKey: "engagementSubtitle",
|
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
prefixes: ["/admin/events", "/admin/polls", "/admin/tickets"],
|
prefixes: ["/admin/events"],
|
||||||
tabs: [
|
tabs: [
|
||||||
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
|
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
|
||||||
{ href: "/admin/events/types", labelKey: "eventTypes" },
|
{ href: "/admin/events/types", labelKey: "eventTypes" },
|
||||||
{ href: "/admin/polls", labelKey: "polls", match: ["/admin/polls"] },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tickets",
|
||||||
|
titleKey: "tickets",
|
||||||
|
subtitleKey: "ticketsSubtitle",
|
||||||
|
icon: Ticket,
|
||||||
|
prefixes: ["/admin/tickets"],
|
||||||
|
tabs: [
|
||||||
{
|
{
|
||||||
href: "/admin/tickets",
|
href: "/admin/tickets",
|
||||||
labelKey: "tickets",
|
labelKey: "tickets",
|
||||||
@@ -119,50 +108,19 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "staff-access",
|
||||||
titleKey: "users",
|
titleKey: "staffAccess",
|
||||||
subtitleKey: "usersSubtitle",
|
subtitleKey: "staffAccessSubtitle",
|
||||||
icon: Users,
|
icon: KeyRound,
|
||||||
prefixes: [
|
prefixes: ["/admin/teams", "/admin/permissions", "/admin/housekeeping"],
|
||||||
"/admin/users",
|
|
||||||
"/admin/online",
|
|
||||||
"/admin/applications",
|
|
||||||
"/admin/teams",
|
|
||||||
],
|
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
|
||||||
href: "/admin/users",
|
|
||||||
labelKey: "directory",
|
|
||||||
match: ["/admin/users"],
|
|
||||||
},
|
|
||||||
{ href: "/admin/users/multi-accounts", labelKey: "multiAccounts" },
|
|
||||||
{ href: "/admin/online", labelKey: "online" },
|
|
||||||
{ href: "/admin/applications", labelKey: "applications" },
|
|
||||||
{ href: "/admin/teams", labelKey: "teams" },
|
{ href: "/admin/teams", labelKey: "teams" },
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "access",
|
|
||||||
titleKey: "access",
|
|
||||||
subtitleKey: "accessSubtitle",
|
|
||||||
icon: Shield,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/bans",
|
|
||||||
"/admin/ip",
|
|
||||||
"/admin/vpn",
|
|
||||||
"/admin/permissions",
|
|
||||||
"/admin/wordfilter",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/bans", labelKey: "bans" },
|
|
||||||
{ href: "/admin/ip", labelKey: "ip" },
|
|
||||||
{ href: "/admin/vpn", labelKey: "vpn" },
|
|
||||||
{
|
{
|
||||||
href: "/admin/permissions",
|
href: "/admin/permissions",
|
||||||
labelKey: "permissions",
|
labelKey: "permissions",
|
||||||
match: ["/admin/permissions"],
|
match: ["/admin/permissions"],
|
||||||
},
|
},
|
||||||
{ href: "/admin/wordfilter", labelKey: "wordFilter" },
|
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,50 +140,6 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
{ href: "/admin/moderation/team", labelKey: "team" },
|
{ href: "/admin/moderation/team", labelKey: "team" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "catalog",
|
|
||||||
titleKey: "catalog",
|
|
||||||
subtitleKey: "catalogSubtitle",
|
|
||||||
icon: Store,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/catalog",
|
|
||||||
"/admin/rare-values",
|
|
||||||
"/admin/badges",
|
|
||||||
"/admin/achievements",
|
|
||||||
"/admin/sounds",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/catalog", labelKey: "catalogTab", match: ["/admin/catalog"] },
|
|
||||||
{ href: "/admin/rare-values", labelKey: "rares" },
|
|
||||||
{ href: "/admin/badges", labelKey: "badges" },
|
|
||||||
{ href: "/admin/achievements", labelKey: "achievements" },
|
|
||||||
{ href: "/admin/sounds", labelKey: "sounds" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "shop",
|
|
||||||
titleKey: "shop",
|
|
||||||
subtitleKey: "shopSubtitle",
|
|
||||||
icon: ShoppingCart,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/shop",
|
|
||||||
"/admin/transactions",
|
|
||||||
"/admin/vouchers",
|
|
||||||
"/admin/subscriptions",
|
|
||||||
"/admin/calendar",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/shop", labelKey: "shopTab" },
|
|
||||||
{ href: "/admin/transactions", labelKey: "transactions" },
|
|
||||||
{ href: "/admin/vouchers", labelKey: "vouchers" },
|
|
||||||
{ href: "/admin/subscriptions", labelKey: "subscriptions" },
|
|
||||||
{
|
|
||||||
href: "/admin/calendar",
|
|
||||||
labelKey: "calendar",
|
|
||||||
match: ["/admin/calendar"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "radio",
|
id: "radio",
|
||||||
titleKey: "radio",
|
titleKey: "radio",
|
||||||
@@ -245,7 +159,11 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
labelKey: "monitoring",
|
labelKey: "monitoring",
|
||||||
group: "primary",
|
group: "primary",
|
||||||
},
|
},
|
||||||
{ href: "/admin/radio/settings", labelKey: "settingsTab", group: "primary" },
|
{
|
||||||
|
href: "/admin/radio/settings",
|
||||||
|
labelKey: "settingsTab",
|
||||||
|
group: "primary",
|
||||||
|
},
|
||||||
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
|
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
|
||||||
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
|
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
|
||||||
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
|
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
|
||||||
@@ -255,68 +173,12 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "analytics",
|
||||||
titleKey: "settings",
|
titleKey: "analytics",
|
||||||
subtitleKey: "settingsSubtitle",
|
subtitleKey: "analyticsSubtitle",
|
||||||
icon: Cog,
|
icon: Activity,
|
||||||
prefixes: [
|
prefixes: ["/admin/analytics"],
|
||||||
"/admin/settings",
|
|
||||||
"/admin/theme",
|
|
||||||
"/admin/maintenance",
|
|
||||||
"/admin/favicon",
|
|
||||||
"/admin/emulator",
|
|
||||||
"/admin/email-templates",
|
|
||||||
"/admin/housekeeping",
|
|
||||||
],
|
|
||||||
tabs: [
|
tabs: [
|
||||||
{ href: "/admin/settings", labelKey: "cms" },
|
|
||||||
{ href: "/admin/theme", labelKey: "theme" },
|
|
||||||
{ href: "/admin/maintenance", labelKey: "maintenance" },
|
|
||||||
{ href: "/admin/favicon", labelKey: "favicon" },
|
|
||||||
{ href: "/admin/emulator", labelKey: "emulator" },
|
|
||||||
{ href: "/admin/email-templates", labelKey: "email" },
|
|
||||||
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tools",
|
|
||||||
titleKey: "tools",
|
|
||||||
subtitleKey: "toolsSubtitle",
|
|
||||||
icon: Wrench,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/commandocentrum",
|
|
||||||
"/admin/rooms",
|
|
||||||
"/admin/import",
|
|
||||||
"/admin/translations",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/commandocentrum", labelKey: "commando" },
|
|
||||||
{ href: "/admin/rooms", labelKey: "rooms", match: ["/admin/rooms"] },
|
|
||||||
{ href: "/admin/import", labelKey: "import", match: ["/admin/import"] },
|
|
||||||
{
|
|
||||||
href: "/admin/translations",
|
|
||||||
labelKey: "translations",
|
|
||||||
match: ["/admin/translations"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "observability",
|
|
||||||
titleKey: "observability",
|
|
||||||
subtitleKey: "observabilitySubtitle",
|
|
||||||
icon: Monitor,
|
|
||||||
prefixes: [
|
|
||||||
"/admin/logs",
|
|
||||||
"/admin/analytics",
|
|
||||||
"/admin/devops",
|
|
||||||
"/admin/alerts",
|
|
||||||
],
|
|
||||||
tabs: [
|
|
||||||
{ href: "/admin/logs", labelKey: "logs" },
|
|
||||||
{ href: "/admin/logs/audit", labelKey: "audit" },
|
|
||||||
{ href: "/admin/logs/chat", labelKey: "chat" },
|
|
||||||
{ href: "/admin/logs/commands", labelKey: "commands" },
|
|
||||||
{ href: "/admin/logs/trades", labelKey: "trades" },
|
|
||||||
{
|
{
|
||||||
href: "/admin/analytics",
|
href: "/admin/analytics",
|
||||||
labelKey: "analytics",
|
labelKey: "analytics",
|
||||||
@@ -324,26 +186,38 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
},
|
},
|
||||||
{ href: "/admin/analytics/activity", labelKey: "activity" },
|
{ href: "/admin/analytics/activity", labelKey: "activity" },
|
||||||
{ href: "/admin/analytics/economy", labelKey: "economy" },
|
{ href: "/admin/analytics/economy", labelKey: "economy" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "devops",
|
||||||
|
titleKey: "devops",
|
||||||
|
subtitleKey: "devopsSubtitle",
|
||||||
|
icon: Server,
|
||||||
|
prefixes: ["/admin/devops"],
|
||||||
|
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/alerts", labelKey: "alerts" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
|
||||||
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||||
return (
|
let best: AdminHubDefinition | null = null;
|
||||||
ADMIN_HUBS.find((hub) =>
|
let bestLen = -1;
|
||||||
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
|
for (const hub of ADMIN_HUBS) {
|
||||||
) ?? null
|
for (const prefix of hub.prefixes) {
|
||||||
);
|
const hit = pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||||
|
if (hit && prefix.length > bestLen) {
|
||||||
|
best = hub;
|
||||||
|
bestLen = prefix.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hubPrefixes(id: string): string[] {
|
/** Feature-first sidebar: one entry per feature; hubs only for sibling tabs. */
|
||||||
return ADMIN_HUBS.find((h) => h.id === id)?.prefixes ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Condensed sidebar — one entry per hub (plus dashboard). */
|
|
||||||
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||||
{
|
{
|
||||||
labelKey: "overview",
|
labelKey: "overview",
|
||||||
@@ -362,45 +236,100 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/articles",
|
href: "/admin/articles",
|
||||||
labelKey: "hubNewsMedia",
|
labelKey: "articles",
|
||||||
icon: Newspaper,
|
icon: Newspaper,
|
||||||
matchPrefixes: hubPrefixes("news-media"),
|
matchPrefixes: ["/admin/articles"],
|
||||||
|
},
|
||||||
|
{ href: "/admin/photos", labelKey: "photos", icon: Image },
|
||||||
|
{ href: "/admin/banners", labelKey: "banners", icon: Megaphone },
|
||||||
|
{
|
||||||
|
href: "/admin/ads",
|
||||||
|
labelKey: "advertisements",
|
||||||
|
icon: FileText,
|
||||||
|
matchPrefixes: ["/admin/ads"],
|
||||||
|
},
|
||||||
|
{ href: "/admin/media", labelKey: "media", icon: Image },
|
||||||
|
{ href: "/admin/navigation", labelKey: "navigator", icon: Compass },
|
||||||
|
{
|
||||||
|
href: "/admin/help-questions",
|
||||||
|
labelKey: "helpCenter",
|
||||||
|
icon: HelpCircle,
|
||||||
|
matchPrefixes: ["/admin/help-questions"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/navigation",
|
href: "/admin/writeable-boxes",
|
||||||
labelKey: "hubSiteContent",
|
labelKey: "writeableBoxes",
|
||||||
icon: Compass,
|
icon: Package,
|
||||||
matchPrefixes: hubPrefixes("site-content"),
|
|
||||||
},
|
},
|
||||||
|
{ href: "/admin/tags", labelKey: "tags", icon: Tags },
|
||||||
|
{ href: "/admin/prefixes", labelKey: "prefixes", icon: Sparkles },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "community",
|
||||||
|
icon: Calendar,
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/events",
|
href: "/admin/events",
|
||||||
labelKey: "hubEngagement",
|
labelKey: "events",
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
matchPrefixes: hubPrefixes("engagement"),
|
matchPrefixes: ["/admin/events"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/polls",
|
||||||
|
labelKey: "polls",
|
||||||
|
icon: Vote,
|
||||||
|
matchPrefixes: ["/admin/polls"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/tickets",
|
||||||
|
labelKey: "tickets",
|
||||||
|
icon: Ticket,
|
||||||
|
matchPrefixes: ["/admin/tickets"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "usersAndAccess",
|
labelKey: "usersAndAccess",
|
||||||
icon: Shield,
|
icon: Users,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/users",
|
href: "/admin/users",
|
||||||
labelKey: "hubUsers",
|
labelKey: "users",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
matchPrefixes: hubPrefixes("users"),
|
matchPrefixes: ["/admin/users"],
|
||||||
|
matchExcludePrefixes: ["/admin/users/multi-accounts"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/bans",
|
href: "/admin/users/multi-accounts",
|
||||||
labelKey: "hubAccess",
|
labelKey: "multiAccounts",
|
||||||
icon: Ban,
|
icon: Users,
|
||||||
matchPrefixes: hubPrefixes("access"),
|
|
||||||
},
|
},
|
||||||
|
{ href: "/admin/online", labelKey: "onlineUsers", icon: Wifi },
|
||||||
|
{
|
||||||
|
href: "/admin/applications",
|
||||||
|
labelKey: "applications",
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/teams",
|
||||||
|
labelKey: "staffAccess",
|
||||||
|
icon: KeyRound,
|
||||||
|
matchPrefixes: [
|
||||||
|
"/admin/teams",
|
||||||
|
"/admin/permissions",
|
||||||
|
"/admin/housekeeping",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ href: "/admin/bans", labelKey: "bans", icon: Ban },
|
||||||
|
{ href: "/admin/ip", labelKey: "ipManagement", icon: Shield },
|
||||||
|
{ href: "/admin/vpn", labelKey: "vpn", icon: Shield },
|
||||||
|
{ href: "/admin/wordfilter", labelKey: "wordFilter", icon: Filter },
|
||||||
{
|
{
|
||||||
href: "/admin/moderation",
|
href: "/admin/moderation",
|
||||||
labelKey: "moderation",
|
labelKey: "moderation",
|
||||||
icon: Gavel,
|
icon: Gavel,
|
||||||
matchPrefixes: hubPrefixes("moderation"),
|
matchPrefixes: ["/admin/moderation"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -410,15 +339,27 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/catalog",
|
href: "/admin/catalog",
|
||||||
labelKey: "hubCatalog",
|
labelKey: "catalog",
|
||||||
icon: Store,
|
icon: Store,
|
||||||
matchPrefixes: hubPrefixes("catalog"),
|
matchPrefixes: ["/admin/catalog"],
|
||||||
|
},
|
||||||
|
{ href: "/admin/rare-values", labelKey: "rareValues", icon: Sparkles },
|
||||||
|
{ href: "/admin/badges", labelKey: "badges", icon: BadgeCheck },
|
||||||
|
{ href: "/admin/achievements", labelKey: "achievements", icon: Trophy },
|
||||||
|
{ href: "/admin/sounds", labelKey: "sounds", icon: Volume2 },
|
||||||
|
{ href: "/admin/shop", labelKey: "shop", icon: ShoppingCart },
|
||||||
|
{ href: "/admin/transactions", labelKey: "transactions", icon: Activity },
|
||||||
|
{ href: "/admin/vouchers", labelKey: "vouchers", icon: Ticket },
|
||||||
|
{
|
||||||
|
href: "/admin/subscriptions",
|
||||||
|
labelKey: "subscriptions",
|
||||||
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/shop",
|
href: "/admin/calendar",
|
||||||
labelKey: "hubShop",
|
labelKey: "calendar",
|
||||||
icon: ShoppingCart,
|
icon: CalendarDays,
|
||||||
matchPrefixes: hubPrefixes("shop"),
|
matchPrefixes: ["/admin/calendar"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -438,24 +379,80 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
labelKey: "system",
|
labelKey: "system",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
items: [
|
items: [
|
||||||
|
{ href: "/admin/settings", labelKey: "settings", icon: Cog },
|
||||||
|
{ href: "/admin/theme", labelKey: "theme", icon: Sparkles },
|
||||||
|
{ href: "/admin/maintenance", labelKey: "maintenance", icon: Wrench },
|
||||||
|
{ href: "/admin/favicon", labelKey: "favicon", icon: Image },
|
||||||
|
{ href: "/admin/emulator", labelKey: "emulator", icon: Server },
|
||||||
{
|
{
|
||||||
href: "/admin/settings",
|
href: "/admin/email-templates",
|
||||||
labelKey: "settings",
|
labelKey: "emailTemplates",
|
||||||
icon: Cog,
|
icon: FileText,
|
||||||
matchPrefixes: hubPrefixes("settings"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/admin/commandocentrum",
|
|
||||||
labelKey: "hubTools",
|
|
||||||
icon: Wrench,
|
|
||||||
matchPrefixes: hubPrefixes("tools"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/admin/logs",
|
|
||||||
labelKey: "hubObservability",
|
|
||||||
icon: Monitor,
|
|
||||||
matchPrefixes: hubPrefixes("observability"),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
labelKey: "tools",
|
||||||
|
icon: Wrench,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/admin/commandocentrum",
|
||||||
|
labelKey: "commandocentrum",
|
||||||
|
icon: Terminal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/rooms",
|
||||||
|
labelKey: "rooms",
|
||||||
|
icon: Store,
|
||||||
|
matchPrefixes: ["/admin/rooms"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/import",
|
||||||
|
labelKey: "import",
|
||||||
|
icon: Import,
|
||||||
|
matchPrefixes: ["/admin/import"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/translations",
|
||||||
|
labelKey: "translations",
|
||||||
|
icon: Languages,
|
||||||
|
matchPrefixes: ["/admin/translations"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "monitoring",
|
||||||
|
icon: Monitor,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/admin/logs",
|
||||||
|
labelKey: "logs",
|
||||||
|
icon: FileText,
|
||||||
|
matchPrefixes: ["/admin/logs"],
|
||||||
|
matchExcludePrefixes: [
|
||||||
|
"/admin/logs/audit",
|
||||||
|
"/admin/logs/chat",
|
||||||
|
"/admin/logs/commands",
|
||||||
|
"/admin/logs/trades",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ href: "/admin/logs/audit", labelKey: "auditLog", icon: ClipboardList },
|
||||||
|
{ href: "/admin/logs/chat", labelKey: "chatLog", icon: FileText },
|
||||||
|
{ href: "/admin/logs/commands", labelKey: "commandLog", icon: Terminal },
|
||||||
|
{ href: "/admin/logs/trades", labelKey: "tradeLog", icon: Activity },
|
||||||
|
{
|
||||||
|
href: "/admin/analytics",
|
||||||
|
labelKey: "analytics",
|
||||||
|
icon: Activity,
|
||||||
|
matchPrefixes: ["/admin/analytics"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/devops",
|
||||||
|
labelKey: "devops",
|
||||||
|
icon: Server,
|
||||||
|
matchPrefixes: ["/admin/devops"],
|
||||||
|
},
|
||||||
|
{ href: "/admin/alerts", labelKey: "alerts", icon: AlertTriangle },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -131,7 +131,7 @@ describe("deriveAdminPalette", () => {
|
|||||||
expect(admin.surface).not.toBe("#00ff00");
|
expect(admin.surface).not.toBe("#00ff00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("in light mode mirrors readable public surfaces", () => {
|
it("in light mode softens harsh white public surfaces", () => {
|
||||||
const admin = deriveAdminPalette(
|
const admin = deriveAdminPalette(
|
||||||
{
|
{
|
||||||
color_background: "#f8fafc",
|
color_background: "#f8fafc",
|
||||||
@@ -150,8 +150,8 @@ describe("deriveAdminPalette", () => {
|
|||||||
},
|
},
|
||||||
"light",
|
"light",
|
||||||
);
|
);
|
||||||
expect(admin.canvas).toBe("#f8fafc");
|
expect(admin.canvas).toBe("#eef1f4");
|
||||||
expect(admin.surface).toBe("#ffffff");
|
expect(admin.surface).toBe("#f5f7fa");
|
||||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||||
expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
|
expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
|
||||||
4.5,
|
4.5,
|
||||||
@@ -169,7 +169,7 @@ describe("deriveAdminPalette", () => {
|
|||||||
{ canvas: "#0b0f19", text: "#f8fafc" },
|
{ canvas: "#0b0f19", text: "#f8fafc" },
|
||||||
"light",
|
"light",
|
||||||
);
|
);
|
||||||
expect(admin.canvas).toBe("#ffffff");
|
expect(admin.canvas).toBe("#eef1f4");
|
||||||
expect(admin.text.toLowerCase()).not.toBe("#f8fafc");
|
expect(admin.text.toLowerCase()).not.toBe("#f8fafc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ export interface AdminPalette {
|
|||||||
focusRing: string;
|
focusRing: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pure / near-white public surfaces → soft admin paper (less eye strain). */
|
||||||
|
function softLightSurface(
|
||||||
|
value: string | undefined,
|
||||||
|
softDefault: string,
|
||||||
|
): string {
|
||||||
|
if (!value) return softDefault;
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === "#fff" || v === "#ffffff" || v === "white") return softDefault;
|
||||||
|
// Common slate/zinc paper whites (#f8fafc, #fafafa, #f9fafb, …)
|
||||||
|
if (/^#f[89a-f][0-9a-f]{4}$/i.test(v)) return softDefault;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveAdminPalette(
|
export function deriveAdminPalette(
|
||||||
colors: PublicThemeColors,
|
colors: PublicThemeColors,
|
||||||
overrides?: Partial<AdminPalette>,
|
overrides?: Partial<AdminPalette>,
|
||||||
@@ -34,13 +47,14 @@ export function deriveAdminPalette(
|
|||||||
): AdminPalette {
|
): AdminPalette {
|
||||||
const accent = overrides?.accent ?? colors.color_primary ?? "#f59e0b";
|
const accent = overrides?.accent ?? colors.color_primary ?? "#f59e0b";
|
||||||
|
|
||||||
|
// Soft cool gray-blue paper (not pure white) — easier on the eyes in light HK.
|
||||||
const lightDefaults: AdminPalette = {
|
const lightDefaults: AdminPalette = {
|
||||||
canvas: colors.color_background ?? "#f8fafc",
|
canvas: softLightSurface(colors.color_background, "#eef1f4"),
|
||||||
surface: colors.color_surface ?? "#ffffff",
|
surface: softLightSurface(colors.color_surface, "#f5f7fa"),
|
||||||
surfaceElevated: colors.color_dropdown ?? "#f1f5f9",
|
surfaceElevated: softLightSurface(colors.color_dropdown, "#fafbfc"),
|
||||||
text: colors.color_text ?? "#0f172a",
|
text: colors.color_text ?? "#0f172a",
|
||||||
muted: colors.color_text_muted ?? "#64748b",
|
muted: colors.color_text_muted ?? "#64748b",
|
||||||
border: "#e2e8f0",
|
border: "#d8dee6",
|
||||||
accent,
|
accent,
|
||||||
accentText: accent,
|
accentText: accent,
|
||||||
accentForeground: "#ffffff",
|
accentForeground: "#ffffff",
|
||||||
@@ -48,7 +62,10 @@ export function deriveAdminPalette(
|
|||||||
warning: colors.color_warning ?? "#eab308",
|
warning: colors.color_warning ?? "#eab308",
|
||||||
error: colors.color_error ?? "#ef4444",
|
error: colors.color_error ?? "#ef4444",
|
||||||
info: colors.color_info ?? "#0ea5e9",
|
info: colors.color_info ?? "#0ea5e9",
|
||||||
sidebar: colors.color_navbar ?? colors.color_surface ?? "#ffffff",
|
sidebar: softLightSurface(
|
||||||
|
colors.color_navbar ?? colors.color_surface,
|
||||||
|
"#e8ecf1",
|
||||||
|
),
|
||||||
sidebarText: colors.color_navbar_text ?? colors.color_text ?? "#0f172a",
|
sidebarText: colors.color_navbar_text ?? colors.color_text ?? "#0f172a",
|
||||||
overlay: "rgba(15, 23, 42, 0.45)",
|
overlay: "rgba(15, 23, 42, 0.45)",
|
||||||
focusRing: accent,
|
focusRing: accent,
|
||||||
|
|||||||
+20
-2
@@ -781,7 +781,15 @@
|
|||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping",
|
||||||
"hotelManagementPanel": "Hotel management panel",
|
"hotelManagementPanel": "Hotel management panel",
|
||||||
"rankLabel": "Rank {rank}"
|
"rankLabel": "Rank {rank}",
|
||||||
|
"community": "Community",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"staffAccess": "Ranks & permissions",
|
||||||
|
"multiAccounts": "Multi-accounts",
|
||||||
|
"chatLog": "Chat log",
|
||||||
|
"commandLog": "Command log",
|
||||||
|
"tradeLog": "Trade log",
|
||||||
|
"tools": "Tools"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2153,7 +2161,17 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools"
|
"toolsGroup": "Tools"
|
||||||
}
|
},
|
||||||
|
"events": "Events",
|
||||||
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"ticketsSubtitle": "Support tickets and reply templates",
|
||||||
|
"staffAccess": "Ranks & permissions",
|
||||||
|
"staffAccessSubtitle": "Staff ranks, ACL permissions and housekeeping access",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"analyticsSubtitle": "Hotel activity, retention and economy",
|
||||||
|
"devops": "DevOps",
|
||||||
|
"devopsSubtitle": "Service health and emulator errors"
|
||||||
},
|
},
|
||||||
"wordfilter": {
|
"wordfilter": {
|
||||||
"title": "Word Filter",
|
"title": "Word Filter",
|
||||||
|
|||||||
+24
-6
@@ -673,7 +673,7 @@
|
|||||||
"content": "Contenuti",
|
"content": "Contenuti",
|
||||||
"articles": "Articoli",
|
"articles": "Articoli",
|
||||||
"tags": "Tag",
|
"tags": "Tag",
|
||||||
"navigator": "Navigatore",
|
"navigator": "Navigazione",
|
||||||
"helpCenter": "Centro assistenza",
|
"helpCenter": "Centro assistenza",
|
||||||
"advertisements": "Pubblicità",
|
"advertisements": "Pubblicità",
|
||||||
"writeableBoxes": "Box modificabili",
|
"writeableBoxes": "Box modificabili",
|
||||||
@@ -737,15 +737,23 @@
|
|||||||
"backToSite": "Torna al sito",
|
"backToSite": "Torna al sito",
|
||||||
"hotelManagementPanel": "Pannello di gestione hotel",
|
"hotelManagementPanel": "Pannello di gestione hotel",
|
||||||
"rankLabel": "Grado {rank}",
|
"rankLabel": "Grado {rank}",
|
||||||
"hubNewsMedia": "News & media",
|
"hubNewsMedia": "Notizie e media",
|
||||||
"hubSiteContent": "Site content",
|
"hubSiteContent": "Contenuti sito",
|
||||||
"hubEngagement": "Engagement",
|
"hubEngagement": "Community",
|
||||||
"hubUsers": "Users",
|
"hubUsers": "Users",
|
||||||
"hubAccess": "Access & security",
|
"hubAccess": "Access & security",
|
||||||
"hubCatalog": "Catalog & rares",
|
"hubCatalog": "Catalog & rares",
|
||||||
"hubShop": "Shop & payments",
|
"hubShop": "Shop & payments",
|
||||||
"hubTools": "Tools",
|
"hubTools": "Tools",
|
||||||
"hubObservability": "Observability"
|
"hubObservability": "Observability",
|
||||||
|
"community": "Community",
|
||||||
|
"monitoring": "Monitoraggio",
|
||||||
|
"staffAccess": "Gradi e permessi",
|
||||||
|
"multiAccounts": "Multi-account",
|
||||||
|
"chatLog": "Log chat",
|
||||||
|
"commandLog": "Log comandi",
|
||||||
|
"tradeLog": "Log trade",
|
||||||
|
"tools": "Strumenti"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2396,7 +2404,17 @@
|
|||||||
"errors": "Errori",
|
"errors": "Errori",
|
||||||
"alerts": "Alert",
|
"alerts": "Alert",
|
||||||
"toolsGroup": "Strumenti"
|
"toolsGroup": "Strumenti"
|
||||||
}
|
},
|
||||||
|
"events": "Eventi",
|
||||||
|
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||||
|
"tickets": "Ticket",
|
||||||
|
"ticketsSubtitle": "Ticket di supporto e template risposte",
|
||||||
|
"staffAccess": "Gradi e permessi",
|
||||||
|
"staffAccessSubtitle": "Gradi staff, permessi ACL e accesso housekeeping",
|
||||||
|
"analytics": "Analitiche",
|
||||||
|
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
||||||
|
"devops": "DevOps",
|
||||||
|
"devopsSubtitle": "Salute servizi ed errori emulator"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Impostazioni CMS",
|
"title": "Impostazioni CMS",
|
||||||
|
|||||||
+21
-3
@@ -712,7 +712,7 @@
|
|||||||
"navigator": "Navigator",
|
"navigator": "Navigator",
|
||||||
"helpCenter": "Helpcentrum",
|
"helpCenter": "Helpcentrum",
|
||||||
"advertisements": "Advertenties",
|
"advertisements": "Advertenties",
|
||||||
"writeableBoxes": "Beschrijfbare boxes",
|
"writeableBoxes": "Bewerkbare boxes",
|
||||||
"photos": "Foto's",
|
"photos": "Foto's",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
@@ -781,7 +781,15 @@
|
|||||||
"hubCatalog": "Catalog & rares",
|
"hubCatalog": "Catalog & rares",
|
||||||
"hubShop": "Shop & payments",
|
"hubShop": "Shop & payments",
|
||||||
"hubTools": "Tools",
|
"hubTools": "Tools",
|
||||||
"hubObservability": "Observability"
|
"hubObservability": "Observability",
|
||||||
|
"community": "Community",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"staffAccess": "Rangen & rechten",
|
||||||
|
"multiAccounts": "Multi-accounts",
|
||||||
|
"chatLog": "Chatlog",
|
||||||
|
"commandLog": "Commandolog",
|
||||||
|
"tradeLog": "Tradelog",
|
||||||
|
"tools": "Tools"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2443,7 +2451,17 @@
|
|||||||
"errors": "Fouten",
|
"errors": "Fouten",
|
||||||
"alerts": "Meldingen",
|
"alerts": "Meldingen",
|
||||||
"toolsGroup": "Tools"
|
"toolsGroup": "Tools"
|
||||||
}
|
},
|
||||||
|
"events": "Evenementen",
|
||||||
|
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"ticketsSubtitle": "Supporttickets en antwoordtemplates",
|
||||||
|
"staffAccess": "Rangen & rechten",
|
||||||
|
"staffAccessSubtitle": "Staffrangen, ACL-rechten en housekeeping-toegang",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"analyticsSubtitle": "Hotelactiviteit, retentie en economie",
|
||||||
|
"devops": "DevOps",
|
||||||
|
"devopsSubtitle": "Servicegezondheid en emulatorfouten"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Tickets",
|
"title": "Tickets",
|
||||||
|
|||||||
Reference in new issue
Block a user