diff --git a/package.json b/package.json
index aacaffce..ac0f1a9b 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"bcryptjs": "^3.0.2",
"croner": "^10.0.1",
"hash-wasm": "^4.12.0",
+ "lucide-react": "^1.23.0",
"next": "^16.2.9",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e9c8556..e1e345e6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
+ lucide-react:
+ specifier: ^1.23.0
+ version: 1.23.0(react@19.2.7)
next:
specifier: ^16.2.9
version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2125,6 +2128,11 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
+ lucide-react@1.23.0:
+ resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4012,6 +4020,10 @@ snapshots:
lru.min@1.1.4: {}
+ lucide-react@1.23.0(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index ad9fe021..ea57b367 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -4,76 +4,82 @@ import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
+import {
+ LayoutDashboard, Newspaper, Tags, Compass, HelpCircle, Megaphone, SquarePen, Image,
+ Users, Ban, Globe, Shield, ClipboardList, Group, Key,
+ Store, ShoppingCart, Receipt, Ticket, CreditCard, Gem, Award, Trophy, Calendar,
+ Radio, History,
+ Terminal, Monitor, Bell, FileText, Filter, Wrench, Settings, DoorOpen, Mail, Palette, Cog,
+ ChevronDown, LogOut,
+} from "lucide-react";
export const dynamic = "force-dynamic";
-// Sidebar layout grouped like the original AtomCMS Filament panel (Content,
-// Users & access, Economy, Radio, System) instead of one flat list.
-const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[] = [
+const NAV_GROUPS: { label: string; icon: ReactNode; items: { href: string; label: string; icon: ReactNode }[] }[] = [
{
- label: "Overview",
- items: [{ href: "/admin", label: "Dashboard" }],
+ label: "Overview", icon: ,
+ items: [{ href: "/admin", label: "Dashboard", icon: }],
},
{
- label: "Content",
+ label: "Content", icon: ,
items: [
- { href: "/admin/articles", label: "Articles" },
- { href: "/admin/tags", label: "Tags" },
- { href: "/admin/navigation", label: "Navigator" },
- { href: "/admin/help-questions", label: "Help center" },
- { href: "/admin/ads", label: "Advertisements" },
- { href: "/admin/writeable-boxes", label: "Writeable boxes" },
- { href: "/admin/photos", label: "Photos" },
+ { href: "/admin/articles", label: "Articles", icon: },
+ { href: "/admin/tags", label: "Tags", icon: },
+ { href: "/admin/navigation", label: "Navigator", icon: },
+ { href: "/admin/help-questions", label: "Help center", icon: },
+ { href: "/admin/ads", label: "Advertisements", icon: },
+ { href: "/admin/writeable-boxes", label: "Writeable boxes", icon: },
+ { href: "/admin/photos", label: "Photos", icon: },
],
},
{
- label: "Users & access",
+ label: "Users & access", icon: ,
items: [
- { href: "/admin/users", label: "Users" },
- { href: "/admin/bans", label: "Bans" },
- { href: "/admin/ip", label: "IP management" },
- { href: "/admin/vpn", label: "VPN" },
- { href: "/admin/applications", label: "Applications" },
- { href: "/admin/teams", label: "Teams" },
- { href: "/admin/permissions", label: "Permissions" },
+ { href: "/admin/users", label: "Users", icon: },
+ { href: "/admin/bans", label: "Bans", icon: },
+ { href: "/admin/ip", label: "IP management", icon: },
+ { href: "/admin/vpn", label: "VPN", icon: },
+ { href: "/admin/applications", label: "Applications", icon: },
+ { href: "/admin/teams", label: "Teams", icon: },
+ { href: "/admin/permissions", label: "Permissions", icon: },
],
},
{
- label: "Economy",
+ label: "Economy", icon: ,
items: [
- { href: "/admin/catalog", label: "Catalog" },
- { href: "/admin/shop", label: "Shop" },
- { href: "/admin/transactions", label: "Transactions" },
- { href: "/admin/vouchers", label: "Vouchers" },
- { href: "/admin/subscriptions", label: "Subscriptions" },
- { href: "/admin/rare-values", label: "Rare values" },
- { href: "/admin/badges", label: "Badges" },
- { href: "/admin/achievements", label: "Achievements" },
- { href: "/admin/calendar", label: "Calendar" },
+ { href: "/admin/catalog", label: "Catalog", icon: },
+ { href: "/admin/shop", label: "Shop", icon: },
+ { href: "/admin/transactions", label: "Transactions", icon: },
+ { href: "/admin/vouchers", label: "Vouchers", icon: },
+ { href: "/admin/subscriptions", label: "Subscriptions", icon: },
+ { href: "/admin/rare-values", label: "Rare values", icon: },
+ { href: "/admin/badges", label: "Badges", icon: },
+ { href: "/admin/achievements", label: "Achievements", icon: },
+ { href: "/admin/calendar", label: "Calendar", icon: },
],
},
{
- label: "Radio",
+ label: "Radio", icon: ,
items: [
- { href: "/admin/radio", label: "Radio" },
- { href: "/admin/radio/history", label: "Radio history" },
+ { href: "/admin/radio", label: "Radio", icon: },
+ { href: "/admin/radio/history", label: "Radio history", icon: },
],
},
{
- label: "System",
+ label: "System", icon: ,
items: [
- { href: "/admin/commandocentrum", label: "Commandocentrum" },
- { href: "/admin/emulator", label: "Emulator" },
- { href: "/admin/alerts", label: "Alerts" },
- { href: "/admin/logs", label: "Logs" },
- { href: "/admin/wordfilter", label: "Word filter" },
- { href: "/admin/maintenance", label: "Maintenance" },
- { href: "/admin/housekeeping", label: "Housekeeping" },
- { href: "/admin/rooms", label: "Rooms" },
- { href: "/admin/email-templates", label: "Email templates" },
- { href: "/admin/theme", label: "Theme" },
- { href: "/admin/media", label: "Media" },
- { href: "/admin/settings", label: "Settings" },
+ { href: "/admin/commandocentrum", label: "Commandocentrum", icon: },
+ { href: "/admin/emulator", label: "Emulator", icon: },
+ { href: "/admin/alerts", label: "Alerts", icon: },
+ { href: "/admin/logs", label: "Logs", icon: },
+ { href: "/admin/wordfilter", label: "Word filter", icon: },
+ { href: "/admin/maintenance", label: "Maintenance", icon: },
+ { href: "/admin/housekeeping", label: "Housekeeping", icon: },
+ { href: "/admin/rooms", label: "Rooms", icon: },
+ { href: "/admin/email-templates", label: "Email templates", icon: },
+ { href: "/admin/theme", label: "Theme", icon: },
+ { href: "/admin/media", label: "Media", icon: },
+ { href: "/admin/settings", label: "Settings", icon: },
],
},
];
@@ -81,7 +87,6 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[]
export default async function AdminLayout({ children }: { children: ReactNode }) {
const staff = await requireStaff();
- // Force staff 2FA before the admin panel (AtomCMS ForceStaffTwoFactorMiddleware).
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
@@ -89,49 +94,81 @@ export default async function AdminLayout({ children }: { children: ReactNode })
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
- const initial = staff.username.charAt(0).toUpperCase();
-
return (
-
-
-
-
-
- Housekeeping
-
- Signed in as {staff.username}
-
-
-
+
);
}
+
+function Sidebar({ staff }: { staff: { id: number; username: string; rank: number } }) {
+ const initial = staff.username.charAt(0).toUpperCase();
+
+ return (
+
+ );
+}
+
+function Topbar({ staff }: { staff: { id: number; username: string; rank: number } }) {
+ return (
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 5faabcaf..003fb3b8 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
+import { Users, Newspaper, Ban, Database, Activity, ArrowRight } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -23,11 +24,9 @@ export default async function AdminDashboard() {
prisma.ban.count({ where: { banExpire: { gt: Math.floor(Date.now() / 1000) } } }),
]);
} catch {
- // DB unavailable — show zeroed stats instead of a 500.
}
const dbOk = users >= 0;
- // Users-by-rank breakdown (small inline bar chart, no chart library).
const rankGroups = await prisma.user
.groupBy({ by: ["rank"], _count: { _all: true }, orderBy: { rank: "desc" } })
.catch(() => [] as Array<{ rank: number; _count: { _all: number } }>);
@@ -50,110 +49,76 @@ export default async function AdminDashboard() {
return (
- Dashboard
-
-
-
-
-
0 ? "warn" : "neutral"}
- icon="🔧"
- />
-
+
+
+
+
Dashboard
+
Hotel overview & live status
+
-
- Users by rank
- {rankCounts.length === 0 ? (
-
- {dbOk ? "No accounts to break down yet." : "DB unavailable."}
-
- ) : (
-
- {rankCounts.map((r) => {
- const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
- return (
-
-
- Rank {r.rank}
-
-
-
0 ? 4 : 0)}%`,
- background: "var(--color-primary, #eeb425)",
- }}
- />
-
-
{r.count}
-
- );
- })}
-
- )}
-
+
+ } />
+ } />
+ 0 ? "warn" : "neutral"} icon={} />
+ } />
+
-
-
-
Recent staff activity
-
- View all →
-
-
- {activity.length === 0 ? (
- No staff activity logged yet.
- ) : (
-
-
-
- | When |
- Staff |
- Action |
- Description |
-
-
-
- {activity.map((a) => (
-
- |
- {fromDate(a.createdAt)}
- |
- {nameById.get(Number(a.userId)) ?? `#${a.userId}`} |
-
-
- {a.action}
-
- |
- {a.description} |
-
+
+
+ Users by rank
+ {rankCounts.length === 0 ? (
+ {dbOk ? "No accounts to break down yet." : "DB unavailable."}
+ ) : (
+
+ {rankCounts.map((r) => {
+ const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
+ return (
+
+
Rank {r.rank}
+
+
0 ? 4 : 0)}%` }} />
+
+
{r.count}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
Recent staff activity
+
+ View all
+
+
+ {activity.length === 0 ? (
+ No staff activity logged yet.
+ ) : (
+
+ {activity.map((a, i) => (
+
0 ? "border-t border-[var(--border)]" : ""}`}>
+
+
+
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
+
{a.description}
+
+
+
{a.action}
+
{fromDate(a.createdAt)}
+
+
))}
-
-
- )}
-
+
+ )}
+
+
);
}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
index 91dbe9a7..0d75ee2a 100644
--- a/src/app/admin/users/page.tsx
+++ b/src/app/admin/users/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
+import { ConfirmAction } from "@/components/admin/confirm-action";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -97,48 +98,9 @@ export default async function AdminUsers({
Edit
-
-
-
+ Set Rank
+ Disconnect
+ Alert
|
diff --git a/src/app/globals.css b/src/app/globals.css
index 90c666fe..bf26281d 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -436,7 +436,7 @@ tbody tr:hover {
}
/* ── Admin panel (Housekeeping) ─────────────────────────────────
- Page content wrapper — table card styling and h1 formatting. */
+ Page content wrapper — card-style tables and consistent spacing. */
.admin-page main > h1:first-child,
.admin-page > h1:first-child {
margin-top: 0;
@@ -447,33 +447,35 @@ tbody tr:hover {
border-bottom: 2px solid var(--border);
margin-bottom: 0.85rem;
}
-/* Carded tables — give the bare admin tables a surface + frame. */
.admin-page table {
background: var(--color-surface);
- box-shadow: var(--shadow-card);
border: 1px solid var(--border);
- border-radius: 12px;
+ border-radius: 16px;
overflow: hidden;
+ box-shadow: var(--shadow-card);
}
.admin-page thead th {
- background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
+ background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
color: var(--color-text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
+ font-size: 0.7rem;
+ letter-spacing: 0.04em;
+ padding: 0.7rem 0.9rem;
}
-.admin-page th,
.admin-page td {
- padding: 0.6rem 0.8rem;
+ padding: 0.7rem 0.9rem;
+ border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
+ font-size: 0.88rem;
}
.admin-page tbody tr {
transition: background 0.12s ease;
}
.admin-page tbody tr:hover {
- background: color-mix(in srgb, var(--color-primary) 7%, transparent);
-}
-.admin-page tbody td {
- border-top: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
+ background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
+.admin-page thead tr:first-child th:first-child { border-radius: 16px 0 0 0; }
+.admin-page thead tr:first-child th:last-child { border-radius: 0 16px 0 0; }
/* ── Public design system (faithful to the atom theme) ──────────
content-card = surface card with a primary-tinted header (icon +
title + subtitle) and a padded body — the signature atom block. */
diff --git a/src/components/admin/admin-nav-link.tsx b/src/components/admin/admin-nav-link.tsx
index c4514eeb..9d4a476c 100644
--- a/src/components/admin/admin-nav-link.tsx
+++ b/src/components/admin/admin-nav-link.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
/**
@@ -8,7 +9,7 @@ import { usePathname } from "next/navigation";
* 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.
*/
-export function AdminNavLink({ href, label }: { href: string; label: string }) {
+export function AdminNavLink({ href, icon, children }: { href: string; icon?: ReactNode; children: ReactNode }) {
const pathname = usePathname() ?? "";
const active =
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
@@ -16,11 +17,18 @@ export function AdminNavLink({ href, label }: { href: string; label: string }) {
return (
- {label}
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
{children}
);
}
diff --git a/src/components/admin/confirm-action.tsx b/src/components/admin/confirm-action.tsx
new file mode 100644
index 00000000..27a3eb53
--- /dev/null
+++ b/src/components/admin/confirm-action.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+export function ConfirmAction({
+ message,
+ action,
+ userId,
+ className,
+ style,
+ children,
+}: {
+ message: string;
+ action: string;
+ userId: number;
+ className?: string;
+ style?: React.CSSProperties;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx
index 24113c68..cdf02571 100644
--- a/src/components/admin/dashboard.tsx
+++ b/src/components/admin/dashboard.tsx
@@ -25,26 +25,20 @@ export function StatusCard({
value: ReactNode;
hint?: string;
state?: State;
- icon?: string;
+ icon?: ReactNode;
}) {
- const borderColor =
- state === "ok" ? "border-l-green-600" :
- state === "warn" ? "border-l-[var(--color-primary)]" :
- state === "danger" ? "border-l-red-500" :
- "border-l-[var(--color-text-muted)]";
-
return (
-
-
-
{label}
+
+
+ {label}
{icon ? (
-
+
{icon}
) : null}
-
{value}
- {hint ?
{hint}
: null}
+
{value}
+ {hint ?
{hint}
: null}
);
}