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} - -
-
{children}
+
+ +
+ +
{children}
); } + +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 ( +
+
+

Housekeeping

+ + + Hotel management panel + +
+
+ + {staff.username} + + + rank {staff.rank} + +
+
+ ); +} 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.

- ) : ( - - - - - - - - - - - {activity.map((a) => ( - - - - - - +
+
+

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)}
+
+
))} -
-
WhenStaffActionDescription
- {fromDate(a.createdAt)} - {nameById.get(Number(a.userId)) ?? `#${a.userId}`} - - {a.action} - - {a.description}
- )} -
+
+ )} + +
); } 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}
); }