Reorganize admin into tabbed hubs with a condensed sidebar.
Local Build and Deploy / deploy (push) Successful in 57s
Local Build and Deploy / deploy (push) Successful in 57s
Replace the long flat nav with hub entries, shared AdminHubChrome tabs, and AdminPageShell. Existing URLs stay stable; Settings/Radio and other sections now share one chrome. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
367f69a439
commit
363c9b3d56
30 files changed
+854
-432
No files matched your search
+36
-367
@@ -1,361 +1,18 @@
|
||||
import {
|
||||
Award,
|
||||
Ban,
|
||||
Bell,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
Compass,
|
||||
CreditCard,
|
||||
DoorOpen,
|
||||
FileText,
|
||||
Filter,
|
||||
Gem,
|
||||
Globe,
|
||||
Group,
|
||||
HelpCircle,
|
||||
History,
|
||||
Image,
|
||||
Key,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Music,
|
||||
Newspaper,
|
||||
Palette,
|
||||
Radio,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
SquarePen,
|
||||
Store,
|
||||
Tags,
|
||||
Terminal,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Upload,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
||||
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getNavGroups(t: (key: string) => string) {
|
||||
return [
|
||||
{
|
||||
label: t("overview"),
|
||||
icon: <LayoutDashboard size={14} />,
|
||||
items: [
|
||||
{
|
||||
href: "/admin",
|
||||
label: t("dashboard"),
|
||||
icon: <LayoutDashboard size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("content"),
|
||||
icon: <Newspaper size={14} />,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/articles",
|
||||
label: t("articles"),
|
||||
icon: <Newspaper size={16} />,
|
||||
},
|
||||
{ href: "/admin/tags", label: t("tags"), icon: <Tags size={16} /> },
|
||||
{
|
||||
href: "/admin/navigation",
|
||||
label: t("navigator"),
|
||||
icon: <Compass size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/help-questions",
|
||||
label: t("helpCenter"),
|
||||
icon: <HelpCircle size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: t("advertisements"),
|
||||
icon: <Megaphone size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/writeable-boxes",
|
||||
label: t("writeableBoxes"),
|
||||
icon: <SquarePen size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/photos",
|
||||
label: t("photos"),
|
||||
icon: <Image size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
label: t("tickets"),
|
||||
icon: <MessageSquare size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/events",
|
||||
label: t("events"),
|
||||
icon: <Calendar size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/polls",
|
||||
label: t("polls"),
|
||||
icon: <ClipboardList size={16} />,
|
||||
},
|
||||
{ href: "/admin/banners", label: t("banners"), icon: <Image size={16} /> },
|
||||
{
|
||||
href: "/admin/prefixes",
|
||||
label: t("prefixes"),
|
||||
icon: <Languages size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("usersAndAccess"),
|
||||
icon: <Shield size={14} />,
|
||||
items: [
|
||||
{ href: "/admin/users", label: t("users"), icon: <Users size={16} /> },
|
||||
{ href: "/admin/bans", label: t("bans"), icon: <Ban size={16} /> },
|
||||
{
|
||||
href: "/admin/ip",
|
||||
label: t("ipManagement"),
|
||||
icon: <Globe size={16} />,
|
||||
},
|
||||
{ href: "/admin/vpn", label: t("vpn"), icon: <Shield size={16} /> },
|
||||
{
|
||||
href: "/admin/applications",
|
||||
label: t("applications"),
|
||||
icon: <ClipboardList size={16} />,
|
||||
},
|
||||
{ href: "/admin/teams", label: t("teams"), icon: <Group size={16} /> },
|
||||
{
|
||||
href: "/admin/permissions",
|
||||
label: t("permissions"),
|
||||
icon: <Key size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/online",
|
||||
label: t("onlineUsers"),
|
||||
icon: <Users size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
label: t("moderation"),
|
||||
icon: <Shield size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("economy"),
|
||||
icon: <ShoppingCart size={14} />,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/catalog",
|
||||
label: t("catalog"),
|
||||
icon: <Store size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/shop",
|
||||
label: t("shop"),
|
||||
icon: <ShoppingCart size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/transactions",
|
||||
label: t("transactions"),
|
||||
icon: <Receipt size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/vouchers",
|
||||
label: t("vouchers"),
|
||||
icon: <Ticket size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/subscriptions",
|
||||
label: t("subscriptions"),
|
||||
icon: <CreditCard size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/rare-values",
|
||||
label: t("rareValues"),
|
||||
icon: <Gem size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/badges",
|
||||
label: t("badges"),
|
||||
icon: <Award size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/achievements",
|
||||
label: t("achievements"),
|
||||
icon: <Trophy size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/calendar",
|
||||
label: t("calendar"),
|
||||
icon: <Calendar size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("radio"),
|
||||
icon: <Radio size={14} />,
|
||||
items: [
|
||||
{ href: "/admin/radio", label: t("radio"), icon: <Radio size={16} /> },
|
||||
{
|
||||
href: "/admin/radio/history",
|
||||
label: t("radioHistory"),
|
||||
icon: <History size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/banners",
|
||||
label: t("radioBanners"),
|
||||
icon: <Image size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/embed",
|
||||
label: t("radioEmbed"),
|
||||
icon: <Radio size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/api-keys",
|
||||
label: t("radioApiKeys"),
|
||||
icon: <Key size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/moderation",
|
||||
label: t("radioModeration"),
|
||||
icon: <Shield size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/settings",
|
||||
label: t("radioSettings"),
|
||||
icon: <Settings size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/monitoring",
|
||||
label: t("radioMonitoring"),
|
||||
icon: <Monitor size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/points",
|
||||
label: t("radioPoints"),
|
||||
icon: <Gem size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/ranks",
|
||||
label: t("radioRanks"),
|
||||
icon: <Group size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/autodj",
|
||||
label: t("radioAutodj"),
|
||||
icon: <Radio size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("system"),
|
||||
icon: <Settings size={14} />,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/commandocentrum",
|
||||
label: t("commandocentrum"),
|
||||
icon: <Terminal size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/emulator",
|
||||
label: t("emulator"),
|
||||
icon: <Monitor size={16} />,
|
||||
},
|
||||
{ href: "/admin/alerts", label: t("alerts"), icon: <Bell size={16} /> },
|
||||
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
|
||||
{
|
||||
href: "/admin/logs/audit",
|
||||
label: t("auditLog"),
|
||||
icon: <FileText size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
label: t("analytics"),
|
||||
icon: <LayoutDashboard size={16} />,
|
||||
},
|
||||
{ href: "/admin/devops", label: t("devops"), icon: <Monitor size={16} /> },
|
||||
{
|
||||
href: "/admin/wordfilter",
|
||||
label: t("wordFilter"),
|
||||
icon: <Filter size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/maintenance",
|
||||
label: t("maintenance"),
|
||||
icon: <Wrench size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/housekeeping",
|
||||
label: t("housekeeping"),
|
||||
icon: <Settings size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/rooms",
|
||||
label: t("rooms"),
|
||||
icon: <DoorOpen size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/email-templates",
|
||||
label: t("emailTemplates"),
|
||||
icon: <Mail size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/theme",
|
||||
label: t("theme"),
|
||||
icon: <Palette size={16} />,
|
||||
},
|
||||
{ href: "/admin/media", label: t("media"), icon: <Image size={16} /> },
|
||||
{
|
||||
href: "/admin/favicon",
|
||||
label: t("favicon"),
|
||||
icon: <Image size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/sounds",
|
||||
label: t("sounds"),
|
||||
icon: <Music size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/translations",
|
||||
label: t("translations"),
|
||||
icon: <Languages size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/import",
|
||||
label: t("import"),
|
||||
icon: <Upload size={16} />,
|
||||
},
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: t("settings"),
|
||||
icon: <Cog size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -380,7 +37,9 @@ export default async function AdminLayout({
|
||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||
>
|
||||
<Topbar staff={staff} />
|
||||
<section className="admin-page flex-1">{children}</section>
|
||||
<section className="admin-page flex-1">
|
||||
<AdminHubChrome>{children}</AdminHubChrome>
|
||||
</section>
|
||||
</div>
|
||||
</AdminMobileWrapper>
|
||||
);
|
||||
@@ -393,7 +52,6 @@ async function Sidebar({
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.nav");
|
||||
const initial = staff.username.charAt(0).toUpperCase();
|
||||
const navGroups = getNavGroups(t);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -418,26 +76,37 @@ async function Sidebar({
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{navGroups.map((group, gi) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className={
|
||||
gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
|
||||
{group.icon}
|
||||
<span>{group.label}</span>
|
||||
{ADMIN_NAV_GROUPS.map((group, gi) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<div
|
||||
key={group.labelKey}
|
||||
className={
|
||||
gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
|
||||
<GroupIcon size={14} />
|
||||
<span>{t(group.labelKey)}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<AdminNavLink
|
||||
key={`${group.labelKey}-${item.href}`}
|
||||
href={item.href}
|
||||
matchPrefixes={item.matchPrefixes}
|
||||
icon={<ItemIcon size={16} />}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</AdminNavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<AdminNavLink key={item.href} href={item.href} icon={item.icon}>
|
||||
{item.label}
|
||||
</AdminNavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Cog, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -10,13 +8,6 @@ import { CmsSettingsForm } from "./cms-settings-form";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "CMS Settings" };
|
||||
|
||||
const RELATED = [
|
||||
{ href: "/admin/theme", label: "Theme & colors" },
|
||||
{ href: "/admin/maintenance", label: "Maintenance" },
|
||||
{ href: "/admin/vpn", label: "VPN / proxy block" },
|
||||
{ href: "/admin/emulator", label: "Emulator config" },
|
||||
] as const;
|
||||
|
||||
export default async function AdminSettings() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
@@ -49,34 +40,6 @@ export default async function AdminSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Cog size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
|
||||
CMS Settings
|
||||
</h1>
|
||||
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
|
||||
Configure hotel identity, imaging, client, radio and security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RELATED.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs font-semibold text-[var(--admin-text)] no-underline hover:border-[var(--admin-accent)] transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
<ExternalLink size={12} className="opacity-60" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-row">
|
||||
<span className="admin-stat-chip">
|
||||
{settings.length} keys in database
|
||||
|
||||
@@ -505,6 +505,12 @@ tbody tr:hover {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Hub chrome already shows the section title — soften page-level heroes. */
|
||||
.admin-hub-panel > .space-y-6 > div:first-child > h1.text-3xl {
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Page heading */
|
||||
.admin-page main > h1:first-child,
|
||||
.admin-page > h1:first-child {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
|
||||
import { findAdminHub } from "@/lib/admin-nav";
|
||||
|
||||
/**
|
||||
* When the current path belongs to a hub, wraps children with a shared
|
||||
* title + tab strip. Dashboard and unknown routes render children as-is.
|
||||
*/
|
||||
export function AdminHubChrome({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const hub = findAdminHub(pathname);
|
||||
|
||||
if (!hub) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={hub.icon}
|
||||
title={hub.title}
|
||||
subtitle={hub.subtitle}
|
||||
tabs={<AdminSectionTabs tabs={hub.tabs} />}
|
||||
className="admin-hub-panel"
|
||||
>
|
||||
{children}
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,25 +6,33 @@ import type { ReactNode } from "react";
|
||||
import { useAdminMobile } from "@/components/admin/admin-mobile-wrapper";
|
||||
|
||||
/**
|
||||
* Sidebar link that highlights when its route is active. "/admin" matches
|
||||
* exactly; every other entry also matches its sub-routes (e.g. /admin/radio is
|
||||
* active on /admin/radio/history) so the current section stays lit.
|
||||
* Sidebar link that highlights when its route is active.
|
||||
* Optional matchPrefixes cover multi-page hubs (e.g. Settings → theme/vpn).
|
||||
*/
|
||||
export function AdminNavLink({
|
||||
href,
|
||||
icon,
|
||||
matchPrefixes,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
matchPrefixes?: string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const { close } = useAdminMobile();
|
||||
const active =
|
||||
href === "/admin"
|
||||
? pathname === "/admin"
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
let active: boolean;
|
||||
if (href === "/admin") {
|
||||
active = pathname === "/admin";
|
||||
} else if (matchPrefixes?.length) {
|
||||
active = matchPrefixes.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
} else {
|
||||
active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AdminPageShellProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
tabs?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Shared admin page chrome: icon title, optional actions/tabs, then body. */
|
||||
export function AdminPageShell({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
tabs,
|
||||
children,
|
||||
className = "",
|
||||
}: AdminPageShellProps) {
|
||||
return (
|
||||
<div className={`space-y-5 ${className}`.trim()}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{Icon ? (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center shrink-0">
|
||||
<Icon size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{tabs}
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AdminTab {
|
||||
href: string;
|
||||
label: string;
|
||||
/** Extra prefixes that count as active for this tab (detail routes). */
|
||||
match?: string[];
|
||||
}
|
||||
|
||||
function matchLength(pathname: string, prefix: string): number {
|
||||
if (pathname === prefix) return prefix.length + 1; // prefer exact
|
||||
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
||||
const pathname = usePathname() ?? "";
|
||||
|
||||
let bestIndex = -1;
|
||||
let bestScore = -1;
|
||||
tabs.forEach((tab, index) => {
|
||||
const prefixes = tab.match ?? [tab.href];
|
||||
for (const prefix of prefixes) {
|
||||
const score = matchLength(pathname, prefix);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex gap-1 overflow-x-auto border-b border-[var(--admin-border)] -mx-1 px-1"
|
||||
aria-label="Section"
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const active = index === bestIndex;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"-mb-px shrink-0 rounded-t-lg border-b-2 px-3.5 py-2.5 text-sm font-semibold no-underline transition-colors",
|
||||
active
|
||||
? "border-[var(--admin-accent)] text-[var(--admin-text)] bg-[var(--admin-accent)]/8"
|
||||
: "border-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/5",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Calendar,
|
||||
Cog,
|
||||
Compass,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
Radio,
|
||||
Settings,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface AdminHubTab {
|
||||
href: string;
|
||||
label: string;
|
||||
match?: string[];
|
||||
}
|
||||
|
||||
export interface AdminHubDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: LucideIcon;
|
||||
/** Path prefixes belonging to this hub (for chrome + sidebar active). */
|
||||
prefixes: string[];
|
||||
tabs: AdminHubTab[];
|
||||
}
|
||||
|
||||
export interface AdminNavItem {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
/** When set, sidebar item is active if pathname matches any prefix. */
|
||||
matchPrefixes?: string[];
|
||||
}
|
||||
|
||||
export interface AdminNavGroup {
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminNavItem[];
|
||||
}
|
||||
|
||||
export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
{
|
||||
id: "news-media",
|
||||
title: "News & media",
|
||||
subtitle: "Articles, photos, banners, ads and media library",
|
||||
icon: Newspaper,
|
||||
prefixes: [
|
||||
"/admin/articles",
|
||||
"/admin/photos",
|
||||
"/admin/banners",
|
||||
"/admin/ads",
|
||||
"/admin/media",
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/articles",
|
||||
label: "Articles",
|
||||
match: ["/admin/articles"],
|
||||
},
|
||||
{ href: "/admin/photos", label: "Photos" },
|
||||
{ href: "/admin/banners", label: "Banners" },
|
||||
{ href: "/admin/ads", label: "Ads", match: ["/admin/ads"] },
|
||||
{ href: "/admin/media", label: "Media" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "site-content",
|
||||
title: "Site content",
|
||||
subtitle: "Navigation, help center, boxes, tags and prefixes",
|
||||
icon: Compass,
|
||||
prefixes: [
|
||||
"/admin/navigation",
|
||||
"/admin/help-questions",
|
||||
"/admin/writeable-boxes",
|
||||
"/admin/tags",
|
||||
"/admin/prefixes",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/navigation", label: "Navigator" },
|
||||
{
|
||||
href: "/admin/help-questions",
|
||||
label: "Help",
|
||||
match: ["/admin/help-questions"],
|
||||
},
|
||||
{ href: "/admin/writeable-boxes", label: "Boxes" },
|
||||
{ href: "/admin/tags", label: "Tags" },
|
||||
{ href: "/admin/prefixes", label: "Prefixes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "engagement",
|
||||
title: "Engagement",
|
||||
subtitle: "Events, polls and support tickets",
|
||||
icon: Calendar,
|
||||
prefixes: ["/admin/events", "/admin/polls", "/admin/tickets"],
|
||||
tabs: [
|
||||
{ href: "/admin/events", label: "Events", match: ["/admin/events"] },
|
||||
{ href: "/admin/polls", label: "Polls", match: ["/admin/polls"] },
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
label: "Tickets",
|
||||
match: ["/admin/tickets"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
title: "Users",
|
||||
subtitle: "Directory, online players, applications and teams",
|
||||
icon: Users,
|
||||
prefixes: [
|
||||
"/admin/users",
|
||||
"/admin/online",
|
||||
"/admin/applications",
|
||||
"/admin/teams",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/users", label: "Directory", match: ["/admin/users"] },
|
||||
{ href: "/admin/online", label: "Online" },
|
||||
{ href: "/admin/applications", label: "Applications" },
|
||||
{ href: "/admin/teams", label: "Teams" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
title: "Access & security",
|
||||
subtitle: "Bans, IP, VPN, permissions and word filter",
|
||||
icon: Shield,
|
||||
prefixes: [
|
||||
"/admin/bans",
|
||||
"/admin/ip",
|
||||
"/admin/vpn",
|
||||
"/admin/permissions",
|
||||
"/admin/wordfilter",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/bans", label: "Bans" },
|
||||
{ href: "/admin/ip", label: "IP" },
|
||||
{ href: "/admin/vpn", label: "VPN" },
|
||||
{
|
||||
href: "/admin/permissions",
|
||||
label: "Permissions",
|
||||
match: ["/admin/permissions"],
|
||||
},
|
||||
{ href: "/admin/wordfilter", label: "Word filter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "moderation",
|
||||
title: "Moderation",
|
||||
subtitle: "Reports, actions and moderation team",
|
||||
icon: Shield,
|
||||
prefixes: ["/admin/moderation"],
|
||||
tabs: [
|
||||
{ href: "/admin/moderation", label: "Overview" },
|
||||
{ href: "/admin/moderation/actions", label: "Actions" },
|
||||
{
|
||||
href: "/admin/moderation/cfh",
|
||||
label: "CFH",
|
||||
match: ["/admin/moderation/cfh"],
|
||||
},
|
||||
{ href: "/admin/moderation/team", label: "Team" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "catalog",
|
||||
title: "Catalog & rares",
|
||||
subtitle: "Catalog, rares, badges, achievements and sounds",
|
||||
icon: Store,
|
||||
prefixes: [
|
||||
"/admin/catalog",
|
||||
"/admin/rare-values",
|
||||
"/admin/badges",
|
||||
"/admin/achievements",
|
||||
"/admin/sounds",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/catalog", label: "Catalog", match: ["/admin/catalog"] },
|
||||
{ href: "/admin/rare-values", label: "Rares" },
|
||||
{ href: "/admin/badges", label: "Badges" },
|
||||
{ href: "/admin/achievements", label: "Achievements" },
|
||||
{ href: "/admin/sounds", label: "Sounds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "shop",
|
||||
title: "Shop & payments",
|
||||
subtitle: "Shop, transactions, vouchers, subscriptions and calendar",
|
||||
icon: ShoppingCart,
|
||||
prefixes: [
|
||||
"/admin/shop",
|
||||
"/admin/transactions",
|
||||
"/admin/vouchers",
|
||||
"/admin/subscriptions",
|
||||
"/admin/calendar",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/shop", label: "Shop" },
|
||||
{ href: "/admin/transactions", label: "Transactions" },
|
||||
{ href: "/admin/vouchers", label: "Vouchers" },
|
||||
{ href: "/admin/subscriptions", label: "Subscriptions" },
|
||||
{
|
||||
href: "/admin/calendar",
|
||||
label: "Calendar",
|
||||
match: ["/admin/calendar"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "radio",
|
||||
title: "Radio",
|
||||
subtitle: "Stream, DJ tools, embed and monitoring",
|
||||
icon: Radio,
|
||||
prefixes: ["/admin/radio"],
|
||||
tabs: [
|
||||
{ href: "/admin/radio", label: "Overview" },
|
||||
{ href: "/admin/radio/history", label: "History" },
|
||||
{ href: "/admin/radio/banners", label: "Banners" },
|
||||
{ href: "/admin/radio/embed", label: "Embed" },
|
||||
{ href: "/admin/radio/api-keys", label: "API keys" },
|
||||
{ href: "/admin/radio/moderation", label: "Moderation" },
|
||||
{ href: "/admin/radio/settings", label: "Settings" },
|
||||
{ href: "/admin/radio/monitoring", label: "Monitoring" },
|
||||
{ href: "/admin/radio/points", label: "Points" },
|
||||
{ href: "/admin/radio/ranks", label: "Ranks" },
|
||||
{ href: "/admin/radio/autodj", label: "Auto DJ" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
subtitle: "CMS config, theme, maintenance and related tools",
|
||||
icon: Cog,
|
||||
prefixes: [
|
||||
"/admin/settings",
|
||||
"/admin/theme",
|
||||
"/admin/maintenance",
|
||||
"/admin/favicon",
|
||||
"/admin/emulator",
|
||||
"/admin/email-templates",
|
||||
"/admin/housekeeping",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/settings", label: "CMS" },
|
||||
{ href: "/admin/theme", label: "Theme" },
|
||||
{ href: "/admin/maintenance", label: "Maintenance" },
|
||||
{ href: "/admin/favicon", label: "Favicon" },
|
||||
{ href: "/admin/emulator", label: "Emulator" },
|
||||
{ href: "/admin/email-templates", label: "Email" },
|
||||
{ href: "/admin/housekeeping", label: "Housekeeping" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
title: "Tools",
|
||||
subtitle: "RCON, rooms, import and translations",
|
||||
icon: Wrench,
|
||||
prefixes: [
|
||||
"/admin/commandocentrum",
|
||||
"/admin/rooms",
|
||||
"/admin/import",
|
||||
"/admin/translations",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/commandocentrum", label: "Commando" },
|
||||
{ href: "/admin/rooms", label: "Rooms", match: ["/admin/rooms"] },
|
||||
{ href: "/admin/import", label: "Import", match: ["/admin/import"] },
|
||||
{
|
||||
href: "/admin/translations",
|
||||
label: "Translations",
|
||||
match: ["/admin/translations"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "observability",
|
||||
title: "Observability",
|
||||
subtitle: "Logs, analytics, DevOps and hotel alerts",
|
||||
icon: Monitor,
|
||||
prefixes: [
|
||||
"/admin/logs",
|
||||
"/admin/analytics",
|
||||
"/admin/devops",
|
||||
"/admin/alerts",
|
||||
],
|
||||
tabs: [
|
||||
{ href: "/admin/logs", label: "Logs", match: ["/admin/logs"] },
|
||||
{ href: "/admin/logs/audit", label: "Audit" },
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
label: "Analytics",
|
||||
match: ["/admin/analytics"],
|
||||
},
|
||||
{ href: "/admin/devops", label: "DevOps", match: ["/admin/devops"] },
|
||||
{ href: "/admin/alerts", label: "Alerts" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||
return (
|
||||
ADMIN_HUBS.find((hub) =>
|
||||
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hubPrefixes(id: string): string[] {
|
||||
return ADMIN_HUBS.find((h) => h.id === id)?.prefixes ?? [];
|
||||
}
|
||||
|
||||
/** Condensed sidebar — one entry per hub (plus dashboard). */
|
||||
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{
|
||||
href: "/admin",
|
||||
labelKey: "dashboard",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/articles",
|
||||
labelKey: "hubNewsMedia",
|
||||
icon: Newspaper,
|
||||
matchPrefixes: hubPrefixes("news-media"),
|
||||
},
|
||||
{
|
||||
href: "/admin/navigation",
|
||||
labelKey: "hubSiteContent",
|
||||
icon: Compass,
|
||||
matchPrefixes: hubPrefixes("site-content"),
|
||||
},
|
||||
{
|
||||
href: "/admin/events",
|
||||
labelKey: "hubEngagement",
|
||||
icon: Calendar,
|
||||
matchPrefixes: hubPrefixes("engagement"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "usersAndAccess",
|
||||
icon: Shield,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
labelKey: "hubUsers",
|
||||
icon: Users,
|
||||
matchPrefixes: hubPrefixes("users"),
|
||||
},
|
||||
{
|
||||
href: "/admin/bans",
|
||||
labelKey: "hubAccess",
|
||||
icon: Ban,
|
||||
matchPrefixes: hubPrefixes("access"),
|
||||
},
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
labelKey: "moderation",
|
||||
icon: Shield,
|
||||
matchPrefixes: hubPrefixes("moderation"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "economy",
|
||||
icon: ShoppingCart,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/catalog",
|
||||
labelKey: "hubCatalog",
|
||||
icon: Store,
|
||||
matchPrefixes: hubPrefixes("catalog"),
|
||||
},
|
||||
{
|
||||
href: "/admin/shop",
|
||||
labelKey: "hubShop",
|
||||
icon: ShoppingCart,
|
||||
matchPrefixes: hubPrefixes("shop"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/radio",
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
matchPrefixes: ["/admin/radio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
labelKey: "settings",
|
||||
icon: Cog,
|
||||
matchPrefixes: hubPrefixes("settings"),
|
||||
},
|
||||
{
|
||||
href: "/admin/commandocentrum",
|
||||
labelKey: "hubTools",
|
||||
icon: Wrench,
|
||||
matchPrefixes: hubPrefixes("tools"),
|
||||
},
|
||||
{
|
||||
href: "/admin/logs",
|
||||
labelKey: "hubObservability",
|
||||
icon: Monitor,
|
||||
matchPrefixes: hubPrefixes("observability"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Обратно към сайта",
|
||||
"housekeeping": "Домакинство",
|
||||
"hotelManagementPanel": "Панел за управление на хотел",
|
||||
"rankLabel": "Ранг {rank}"
|
||||
"rankLabel": "Ранг {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Табло за управление",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Zpět na stránku",
|
||||
"housekeeping": "Úklid",
|
||||
"hotelManagementPanel": "Panel správy hotelu",
|
||||
"rankLabel": "Hodnost {rank}"
|
||||
"rankLabel": "Hodnost {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Tilbage til webstedet",
|
||||
"housekeeping": "Husholdning",
|
||||
"hotelManagementPanel": "Hotel management panel",
|
||||
"rankLabel": "Rangering {rank}"
|
||||
"rankLabel": "Rangering {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -719,7 +719,16 @@
|
||||
"backToSite": "Zurück zur Website",
|
||||
"housekeeping": "Hausverwaltung",
|
||||
"hotelManagementPanel": "Hotel-Verwaltungsbereich",
|
||||
"rankLabel": "Rang {rank}"
|
||||
"rankLabel": "Rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Επιστροφή στον ιστότοπο",
|
||||
"housekeeping": "Οικονομία",
|
||||
"hotelManagementPanel": "Πάνελ διαχείρισης ξενοδοχείου",
|
||||
"rankLabel": "Κατάταξη {rank}"
|
||||
"rankLabel": "Κατάταξη {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ταμπλό",
|
||||
|
||||
@@ -690,6 +690,9 @@
|
||||
"overview": "Overview",
|
||||
"dashboard": "Dashboard",
|
||||
"content": "Content",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"articles": "Articles",
|
||||
"tags": "Tags",
|
||||
"navigator": "Navigator",
|
||||
@@ -703,6 +706,8 @@
|
||||
"banners": "Banners",
|
||||
"prefixes": "Prefixes",
|
||||
"usersAndAccess": "Users & access",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"users": "Users",
|
||||
"bans": "Bans",
|
||||
"ipManagement": "IP management",
|
||||
@@ -713,6 +718,8 @@
|
||||
"onlineUsers": "Online users",
|
||||
"moderation": "Moderation",
|
||||
"economy": "Economy",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"catalog": "Catalog",
|
||||
"shop": "Shop",
|
||||
"transactions": "Transactions",
|
||||
@@ -734,6 +741,8 @@
|
||||
"radioRanks": "Ranks",
|
||||
"radioAutodj": "Auto DJ",
|
||||
"system": "System",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability",
|
||||
"commandocentrum": "Commandocentrum",
|
||||
"emulator": "Emulator",
|
||||
"alerts": "Alerts",
|
||||
|
||||
+10
-1
@@ -719,7 +719,16 @@
|
||||
"backToSite": "Volver al sitio",
|
||||
"housekeeping": "Housekeeping",
|
||||
"hotelManagementPanel": "Panel de gestión del hotel",
|
||||
"rankLabel": "Rango {rank}"
|
||||
"rankLabel": "Rango {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
|
||||
+10
-1
@@ -719,7 +719,16 @@
|
||||
"backToSite": "Retour au site",
|
||||
"housekeeping": "Gouvernance",
|
||||
"hotelManagementPanel": "Panneau de gestion de l'hôtel",
|
||||
"rankLabel": "Rang {rank}"
|
||||
"rankLabel": "Rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Povratak na stranicu",
|
||||
"housekeeping": "Domaćinstvo",
|
||||
"hotelManagementPanel": "Upravljačka ploča hotela",
|
||||
"rankLabel": "Rang {rank}"
|
||||
"rankLabel": "Rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Nadzorna ploča",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Vissza a webhelyre",
|
||||
"housekeeping": "Takarítás",
|
||||
"hotelManagementPanel": "Hotel menedzsment panel",
|
||||
"rankLabel": "rang {rank}"
|
||||
"rankLabel": "rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Irányítópult",
|
||||
|
||||
+10
-1
@@ -719,7 +719,16 @@
|
||||
"settings": "Impostazioni",
|
||||
"backToSite": "Torna al sito",
|
||||
"hotelManagementPanel": "Pannello di gestione hotel",
|
||||
"rankLabel": "Grado {rank}"
|
||||
"rankLabel": "Grado {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Terug naar site",
|
||||
"housekeeping": "Housekeeping",
|
||||
"hotelManagementPanel": "Hotelbeheerpaneel",
|
||||
"rankLabel": "Rang {rank}"
|
||||
"rankLabel": "Rang {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Tilbake til nettstedet",
|
||||
"housekeeping": "Rengjøring",
|
||||
"hotelManagementPanel": "Hotellstyringspanel",
|
||||
"rankLabel": "Rangering {rank}"
|
||||
"rankLabel": "Rangering {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashbord",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Powrót do serwisu",
|
||||
"housekeeping": "Sprzątanie",
|
||||
"hotelManagementPanel": "Panel zarządzania hotelem",
|
||||
"rankLabel": "Ranga {rank}"
|
||||
"rankLabel": "Ranga {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit nawigacyjny",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Voltar ao site",
|
||||
"housekeeping": "Limpeza",
|
||||
"hotelManagementPanel": "Painel de gestão hoteleira",
|
||||
"rankLabel": "Classificação {rank}"
|
||||
"rankLabel": "Classificação {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Înapoi la site",
|
||||
"housekeeping": "Menaj",
|
||||
"hotelManagementPanel": "Panoul de management al hotelului",
|
||||
"rankLabel": "Clasament {rank}"
|
||||
"rankLabel": "Clasament {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tabloul de bord",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Вернуться на сайт",
|
||||
"housekeeping": "Ведение домашнего хозяйства",
|
||||
"hotelManagementPanel": "Панель управления отелем",
|
||||
"rankLabel": "Ранг {rank}"
|
||||
"rankLabel": "Ранг {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Späť na stránku",
|
||||
"housekeeping": "Upratovanie",
|
||||
"hotelManagementPanel": "Panel riadenia hotela",
|
||||
"rankLabel": "Hodnotenie {rank}"
|
||||
"rankLabel": "Hodnotenie {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Назад на сајт",
|
||||
"housekeeping": "Домаћинство",
|
||||
"hotelManagementPanel": "Панел за управљање хотелом",
|
||||
"rankLabel": "Ранг {rank}"
|
||||
"rankLabel": "Ранг {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Дасхбоард",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Tillbaka till webbplatsen",
|
||||
"housekeeping": "Städning",
|
||||
"hotelManagementPanel": "Hotellledningspanel",
|
||||
"rankLabel": "Rank {rank}"
|
||||
"rankLabel": "Rank {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Instrumentbräda",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Siteye geri dön",
|
||||
"housekeeping": "Temizlik",
|
||||
"hotelManagementPanel": "Otel yönetim paneli",
|
||||
"rankLabel": "Sıra {rank}"
|
||||
"rankLabel": "Sıra {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
|
||||
+10
-1
@@ -755,7 +755,16 @@
|
||||
"backToSite": "Повернутися на сайт",
|
||||
"housekeeping": "Прибирання",
|
||||
"hotelManagementPanel": "Панель управління готелем",
|
||||
"rankLabel": "Рейтинг {rank}"
|
||||
"rankLabel": "Рейтинг {rank}",
|
||||
"hubNewsMedia": "News & media",
|
||||
"hubSiteContent": "Site content",
|
||||
"hubEngagement": "Engagement",
|
||||
"hubUsers": "Users",
|
||||
"hubAccess": "Access & security",
|
||||
"hubCatalog": "Catalog & rares",
|
||||
"hubShop": "Shop & payments",
|
||||
"hubTools": "Tools",
|
||||
"hubObservability": "Observability"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Приладова панель",
|
||||
|
||||
Reference in new issue
Block a user