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
+526 -305

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 { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper"; import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminNavLink } from "@/components/admin/admin-nav-link"; import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard"; 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" className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }} style={{ backgroundColor: "var(--admin-canvas)" }}
> >
<Topbar staff={staff} /> <AdminTopbar staff={staff} />
<section className="admin-page flex-1"> <section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome> <AdminHubChrome>{children}</AdminHubChrome>
</section> </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, Shield,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { saveManagedSettings } from "@/actions/admin-settings"; import { saveManagedSettings } from "@/actions/admin-settings";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -45,6 +46,7 @@ export function CmsSettingsForm({
}) { }) {
const [values, setValues] = useState(initialValues); const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction(); const { isPending, run } = useServerAction();
const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) { function handleChange(key: string, value: string) {
setValues((prev) => ({ ...prev, [key]: value })); setValues((prev) => ({ ...prev, [key]: value }));
@@ -58,8 +60,8 @@ export function CmsSettingsForm({
normalized[key] = values[key] === "1" ? "1" : "0"; normalized[key] = values[key] === "1" ? "1" : "0";
} }
run(() => saveManagedSettings({ settings: normalized }), { run(() => saveManagedSettings({ settings: normalized }), {
successMessage: "Settings saved.", successMessage: t("saved"),
errorMessage: "Could not save settings.", errorMessage: t("saveFailed"),
}); });
} }
@@ -83,13 +85,11 @@ export function CmsSettingsForm({
) : ( ) : (
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
)} )}
Save settings {isPending ? t("form.saving") : t("saveSettings")}
</Button> </Button>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
You can view settings but need edit permission to change them.
</p>
)} )}
</form> </form>
); );
+7 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -18,6 +19,7 @@ export default async function AdminSettings() {
PERMS.SETTINGS_EDIT, PERMS.SETTINGS_EDIT,
session.user.rank, session.user.rank,
); );
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = []; let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try { try {
@@ -42,12 +44,14 @@ export default async function AdminSettings() {
<div className="space-y-8"> <div className="space-y-8">
<div className="admin-stat-row"> <div className="admin-stat-row">
<span className="admin-stat-chip"> <span className="admin-stat-chip">
{settings.length} keys in database {t("keysInDatabase", { count: settings.length })}
</span> </span>
<span className="admin-stat-chip"> <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>
<span className="admin-stat-chip">{documented} documented</span>
</div> </div>
<CmsSettingsForm values={values} canEdit={canEdit} /> <CmsSettingsForm values={values} canEdit={canEdit} />
+12 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs"; import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
@@ -13,15 +14,23 @@ import { findAdminHub } from "@/lib/admin-nav";
export function AdminHubChrome({ children }: { children: ReactNode }) { export function AdminHubChrome({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname); const hub = findAdminHub(pathname);
const t = useTranslations("pages.admin.hubs");
if (!hub) return <>{children}</>; 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 ( return (
<AdminPageShell <AdminPageShell
icon={hub.icon} icon={hub.icon}
title={hub.title} title={t(hub.titleKey)}
subtitle={hub.subtitle} subtitle={t(hub.subtitleKey)}
tabs={<AdminSectionTabs tabs={hub.tabs} />} tabs={<AdminSectionTabs tabs={tabs} />}
className="admin-hub-panel" className="admin-hub-panel"
> >
{children} {children}
+7 -10
View File
@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface AdminTab { export interface AdminTab {
@@ -13,11 +14,6 @@ export interface AdminTab {
group?: string; group?: string;
} }
const GROUP_LABELS: Record<string, string> = {
primary: "",
tools: "Tools",
};
function matchLength(pathname: string, prefix: string): number { function matchLength(pathname: string, prefix: string): number {
if (pathname === prefix) return prefix.length + 1; // prefer exact if (pathname === prefix) return prefix.length + 1; // prefer exact
if (pathname.startsWith(`${prefix}/`)) return prefix.length; if (pathname.startsWith(`${prefix}/`)) return prefix.length;
@@ -71,6 +67,7 @@ function TabRow({
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) { export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const t = useTranslations("pages.admin.hubs");
let bestIndex = -1; let bestIndex = -1;
let bestScore = -1; let bestScore = -1;
@@ -87,9 +84,7 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const hasGroups = tabs.some((t) => t.group); const hasGroups = tabs.some((t) => t.group);
if (!hasGroups) { if (!hasGroups) {
return ( return <TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />;
<TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />
);
} }
const order: string[] = []; const order: string[] = [];
@@ -102,16 +97,18 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{order.map((groupId) => { {order.map((groupId) => {
const groupTabs = tabs.filter((t) => (t.group ?? "primary") === groupId); const groupTabs = tabs.filter((tab) => (tab.group ?? "primary") === groupId);
const rowOffset = offset; const rowOffset = offset;
offset += groupTabs.length; offset += groupTabs.length;
const label =
groupId === "tools" ? t("tabs.toolsGroup") : undefined;
return ( return (
<TabRow <TabRow
key={groupId} key={groupId}
tabs={groupTabs} tabs={groupTabs}
bestIndex={bestIndex} bestIndex={bestIndex}
globalOffset={rowOffset} 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