Improve admin UX: hub i18n, dynamic topbar, settings strings, staff logs DataTable.
Local Build and Deploy / deploy (push) Successful in 1m12s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:14:55 +02:00
1 parent 604e63da51
commit 1039044e48
11 files changed
+524 -303

No files matched your search

+2 -34
View File
@@ -6,6 +6,7 @@ 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 { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
@@ -38,7 +39,7 @@ export default async function AdminLayout({
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Topbar staff={staff} />
<AdminTopbar staff={staff} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
@@ -128,36 +129,3 @@ async function Sidebar({
);
}
async function Topbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
"use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface StaffActivityRow {
id: number;
when: string;
staff: string;
action: string;
description: string;
target: string;
ip: string;
}
export function StaffActivitiesTable({
data,
}: {
data: PaginatedResult<StaffActivityRow>;
}) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<StaffActivityRow>[] = [
{ key: "when", label: t("colWhen"), sortable: true },
{ key: "staff", label: t("colStaff"), sortable: true },
{
key: "action",
label: t("colAction"),
sortable: true,
render: (value) => (
<Badge variant="secondary" className="font-mono text-[0.7rem]">
{String(value)}
</Badge>
),
},
{ key: "description", label: t("colDescription") },
{ key: "target", label: t("colTarget") },
{ key: "ip", label: t("colIp") },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{
label: t("authorizationErrors"),
params: { filter_authorization: "1" },
},
]}
/>
);
}
+6 -6
View File
@@ -11,6 +11,7 @@ import {
Shield,
UserPlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { saveManagedSettings } from "@/actions/admin-settings";
import { Button } from "@/components/ui/button";
@@ -45,6 +46,7 @@ export function CmsSettingsForm({
}) {
const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction();
const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -58,8 +60,8 @@ export function CmsSettingsForm({
normalized[key] = values[key] === "1" ? "1" : "0";
}
run(() => saveManagedSettings({ settings: normalized }), {
successMessage: "Settings saved.",
errorMessage: "Could not save settings.",
successMessage: t("saved"),
errorMessage: t("saveFailed"),
});
}
@@ -83,13 +85,11 @@ export function CmsSettingsForm({
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save settings
{isPending ? t("form.saving") : t("saveSettings")}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
You can view settings but need edit permission to change them.
</p>
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
)}
</form>
);
+7 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -18,6 +19,7 @@ export default async function AdminSettings() {
PERMS.SETTINGS_EDIT,
session.user.rank,
);
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try {
@@ -42,12 +44,14 @@ export default async function AdminSettings() {
<div className="space-y-8">
<div className="admin-stat-row">
<span className="admin-stat-chip">
{settings.length} keys in database
{t("keysInDatabase", { count: settings.length })}
</span>
<span className="admin-stat-chip">
{MANAGED_SETTING_KEYS.length} managed in form
{t("managedInForm", { count: MANAGED_SETTING_KEYS.length })}
</span>
<span className="admin-stat-chip">
{t("documented", { count: documented })}
</span>
<span className="admin-stat-chip">{documented} documented</span>
</div>
<CmsSettingsForm values={values} canEdit={canEdit} />
+12 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
@@ -13,15 +14,23 @@ import { findAdminHub } from "@/lib/admin-nav";
export function AdminHubChrome({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const t = useTranslations("pages.admin.hubs");
if (!hub) return <>{children}</>;
const tabs = hub.tabs.map((tab) => ({
href: tab.href,
label: t(`tabs.${tab.labelKey}`),
match: tab.match,
group: tab.group,
}));
return (
<AdminPageShell
icon={hub.icon}
title={hub.title}
subtitle={hub.subtitle}
tabs={<AdminSectionTabs tabs={hub.tabs} />}
title={t(hub.titleKey)}
subtitle={t(hub.subtitleKey)}
tabs={<AdminSectionTabs tabs={tabs} />}
className="admin-hub-panel"
>
{children}
+7 -10
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
export interface AdminTab {
@@ -13,11 +14,6 @@ export interface AdminTab {
group?: string;
}
const GROUP_LABELS: Record<string, string> = {
primary: "",
tools: "Tools",
};
function matchLength(pathname: string, prefix: string): number {
if (pathname === prefix) return prefix.length + 1; // prefer exact
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
@@ -71,6 +67,7 @@ function TabRow({
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? "";
const t = useTranslations("pages.admin.hubs");
let bestIndex = -1;
let bestScore = -1;
@@ -87,9 +84,7 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const hasGroups = tabs.some((t) => t.group);
if (!hasGroups) {
return (
<TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />
);
return <TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />;
}
const order: string[] = [];
@@ -102,16 +97,18 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
return (
<div className="space-y-3">
{order.map((groupId) => {
const groupTabs = tabs.filter((t) => (t.group ?? "primary") === groupId);
const groupTabs = tabs.filter((tab) => (tab.group ?? "primary") === groupId);
const rowOffset = offset;
offset += groupTabs.length;
const label =
groupId === "tools" ? t("tabs.toolsGroup") : undefined;
return (
<TabRow
key={groupId}
tabs={groupTabs}
bestIndex={bestIndex}
globalOffset={rowOffset}
label={GROUP_LABELS[groupId] || undefined}
label={label}
/>
);
})}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
export function AdminTopbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs");
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{tNav("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{tNav("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+101 -99
View File
File diff suppressed because it is too large. Load diff
+126 -1
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff