44 Commits

Author SHA1 Message Date
ee7502269f version bump 2026-03-24 21:25:43 +01:00
79ba39076e fix: update configs according to rulebook 2026-03-24 21:25:36 +01:00
38f20beaa6 feat: use normalized track names and save only touched configs 2026-03-24 21:15:25 +01:00
a028dc8242 bump version 2026-03-23 22:37:15 +01:00
53b35c13b4 fix: server card in cols 2026-03-23 22:37:10 +01:00
8996cbbfa0 fix: server card layout 2026-03-23 22:36:33 +01:00
9999f843e5 bump version 2026-03-23 22:22:07 +01:00
0153085180 fix: build errors 2026-03-23 22:21:52 +01:00
9c23a720d1 bump version 2026-03-23 22:14:39 +01:00
cc4afd2294 fix: remove console log 2026-03-23 22:14:30 +01:00
2d85b63775 feat: add server column to global activity log 2026-03-23 22:12:10 +01:00
36c31fa790 feat: add server log preview 2026-03-23 21:13:54 +01:00
c7ef3e9d27 feat: activity log system 2026-03-23 21:05:37 +01:00
50d8662904 version bump 2026-03-22 12:37:55 +01:00
5a4457d369 Merge branch 'feature/redesign' 2026-03-22 12:37:37 +01:00
4cc005d309 feat: api health check and version display 2026-03-22 12:34:33 +01:00
9d22027f23 fix: lift editor form state to parent so it persists across tab switches 2026-03-22 00:19:49 +01:00
de41d2566c feat: update favicon 2026-03-21 23:12:06 +01:00
004ba49de3 fix: extend server cards 2026-03-21 22:49:10 +01:00
3cf56b335c fix: add missing logout button 2026-03-21 22:42:22 +01:00
b2f73a0b95 fix: create modal sizing 2026-03-21 22:20:41 +01:00
5360bb51c4 feat: restyle LogoutButton and RefreshButton, complete redesign 2026-03-21 22:18:33 +01:00
71fa228013 refactor: remove gh/GitHub references from tokens, classes, and component names
Rename all --color-gh-* CSS tokens to --color-*, update Tailwind class names
(bg-gh-*, text-gh-*, border-gh-*, etc.) across all TSX/TS files, rename
GhButton component and file to Button, and remove trivial descriptive comments.
2026-03-21 22:11:13 +01:00
3c643d5dcc feat: restyle server create/delete modals to GitHub-dark
Apply GH-dark design tokens to Modal container, CreateServerModal, and
DeleteServerModal: overlay/canvas card pattern, form-input class, GhButton
variants (primary, ghost, danger-solid), and GH token text/border colors.
2026-03-21 21:57:57 +01:00
c99eebde05 feat: restyle membership page — filters, user table, modals 2026-03-21 21:51:26 +01:00
4f7705ec7c fix: use GhButton for leaderboard save button for consistent focus ring 2026-03-21 21:49:00 +01:00
8a7299fba2 feat: restyle leaderboard tab — pill nav, unsaved changes bar 2026-03-21 21:45:21 +01:00
db0b1491bd fix: add focus:ring-1 to checkbox elements in config editors 2026-03-21 21:44:15 +01:00
097d9d99fc feat: restyle all 5 config form tabs to GitHub-dark
Replace gray-* tokens with gh-* design tokens across all config editors:
labels → text-gh-secondary, headings → border-gh-border-muted/text-gh-primary,
checkboxes → gh-overlay/gh-green/gh-blue, buttons → gh-btn-green, selects → form-select.
2026-03-21 21:42:01 +01:00
14faaf87b7 fix: correct operator precedence in totalPlaytime calculation 2026-03-21 21:30:06 +01:00
9d240c3a80 feat: restyle statistics tab — stat cards, charts, recent sessions table 2026-03-21 21:27:18 +01:00
374899e839 fix: remove duplicate maxConnections info, use h-screen utility 2026-03-21 21:24:39 +01:00
da92ec72a7 feat: restyle server detail — breadcrumb topbar, info strip, tab bar 2026-03-21 21:18:33 +01:00
a5e50fe8b7 fix: remove duplicate card subtitle, improve disable logic, inclusive offline count 2026-03-21 21:16:26 +01:00
025c021896 feat: restyle dashboard — card grid (desktop), row list (mobile), status badges 2026-03-21 21:12:50 +01:00
a4ff0a9339 fix: use matching border color for login button resting state 2026-03-21 21:09:39 +01:00
45a67ebfb8 feat: restyle login page to GitHub-dark 2026-03-21 21:07:36 +01:00
5a7485f654 fix: explicit sidebar active state, popup clears mobile bottom bar 2026-03-21 21:06:25 +01:00
710ac253e2 feat: add AppShell with persistent icon sidebar and mobile bottom tab bar 2026-03-21 20:59:09 +01:00
5c69ef716b fix: use design tokens for primary button green, add gap to GhButton 2026-03-21 20:56:43 +01:00
1d13def6da feat: add Badge, StatusDot, GhButton shared UI primitives 2026-03-21 20:41:06 +01:00
2801f80b9a feat: add GitHub-dark design tokens via Tailwind v4 @theme 2026-03-21 20:37:15 +01:00
75d88257c8 resolve warnings and type errors 2026-03-19 21:31:07 +01:00
f8510c8db3 update fastestLapDriverId to be nullable 2026-03-01 17:03:39 +01:00
114 changed files with 1925 additions and 1130 deletions

0
.gitignore vendored Normal file → Executable file
View File

0
.prettierignore Normal file → Executable file
View File

0
.prettierrc Normal file → Executable file
View File

0
Dockerfile Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
eslint.config.mjs Normal file → Executable file
View File

0
next.config.ts Normal file → Executable file
View File

0
package-lock.json generated Normal file → Executable file
View File

2
package.json Normal file → Executable file
View File

@@ -1,6 +1,6 @@
{ {
"name": "acc-server-manager-web", "name": "acc-server-manager-web",
"version": "0.20.2", "version": "0.24.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",

0
postcss.config.mjs Normal file → Executable file
View File

0
public/file.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

0
public/globe.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

0
public/next.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

0
public/vercel.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

0
public/window.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,16 @@
import { requireAuth } from '@/lib/auth/server';
import { getServers } from '@/lib/api/server/servers';
import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
export default async function ActivityLogPage() {
const session = await requireAuth();
const servers = await getServers(session.token!);
const serverOptions = servers.map((s) => ({ id: s.id, name: s.name }));
return (
<main className="p-5">
<h1 className="mb-5 text-lg font-semibold text-primary">Activity Log</h1>
<ActivityLogTable servers={serverOptions} />
</main>
);
}

23
src/app/dashboard/layout.tsx Normal file → Executable file
View File

@@ -3,19 +3,28 @@ import { SteamCMDProvider } from '@/lib/context/SteamCMDContext';
import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext'; import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer'; import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { AppShell } from '@/components/ui/AppShell';
import { hasPermission } from '@/lib/schemas';
import { checkHealth } from '@/lib/api/server/health';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({ export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) {
children
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await requireAuth(); const session = await requireAuth();
const health = await checkHealth();
if (!health.healthy) {
redirect('/login');
}
const showUsers = hasPermission(session.user!, 'membership.view');
return ( return (
<WebSocketProvider openToken={session.openToken!}> <WebSocketProvider openToken={session.openToken!}>
<SteamCMDProvider> <SteamCMDProvider>
<ServerCreationPopupProvider> <ServerCreationPopupProvider>
{children} <AppShell showUsers={showUsers}>
<ServerCreationPopupContainer /> {children}
<ServerCreationPopupContainer />
</AppShell>
</ServerCreationPopupProvider> </ServerCreationPopupProvider>
</SteamCMDProvider> </SteamCMDProvider>
</WebSocketProvider> </WebSocketProvider>

20
src/app/dashboard/membership/page.tsx Normal file → Executable file
View File

@@ -4,24 +4,26 @@ import type { UserListParams } from '@/lib/api/server/membership';
import { UserManagementTable } from '@/components/membership/UserManagementTable'; import { UserManagementTable } from '@/components/membership/UserManagementTable';
interface MembershipPageProps { interface MembershipPageProps {
searchParams: { searchParams: Promise<{
username?: string; username?: string;
role_name?: string; role_name?: string;
sort_by?: string; sort_by?: string;
sort_desc?: string; sort_desc?: string;
page?: string; page?: string;
}; }>;
} }
export default async function MembershipPage({ searchParams }: MembershipPageProps) { export default async function MembershipPage({ searchParams }: MembershipPageProps) {
const session = await requireAuth(); const session = await requireAuth();
const resolvedParams = await searchParams;
const params: UserListParams = { const params: UserListParams = {
username: searchParams.username, username: resolvedParams.username,
role_name: searchParams.role_name, role_name: resolvedParams.role_name,
sort_by: searchParams.sort_by || 'username', sort_by: resolvedParams.sort_by || 'username',
sort_desc: searchParams.sort_desc === 'true', sort_desc: resolvedParams.sort_desc === 'true',
page: parseInt(searchParams.page || '1'), page: parseInt(resolvedParams.page || '1'),
limit: 20 limit: 20
}; };
@@ -31,8 +33,6 @@ export default async function MembershipPage({ searchParams }: MembershipPagePro
]); ]);
return ( return (
<div className="min-h-screen bg-gray-900 text-white"> <UserManagementTable initialData={userListData} roles={roles} currentUser={session.user!} />
<UserManagementTable initialData={userListData} roles={roles} currentUser={session.user!} />
</div>
); );
} }

43
src/app/dashboard/page.tsx Normal file → Executable file
View File

@@ -1,53 +1,16 @@
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { getServers } from '@/lib/api/server/servers'; import { getServers } from '@/lib/api/server/servers';
import { hasPermission } from '@/lib/schemas';
import Link from 'next/link';
import { ServerListWithActions } from '@/components/server/ServerListWithActions'; import { ServerListWithActions } from '@/components/server/ServerListWithActions';
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification'; import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
import LogoutButton from '@/components/ui/LogoutButton';
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await requireAuth(); const session = await requireAuth();
const servers = await getServers(session.token!); const servers = await getServers(session.token!);
return ( return (
<div className="min-h-screen bg-gray-900 text-white"> <main>
<header className="bg-gray-800 shadow-md">
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-bold">ACC Server Manager</h1>
<div className="flex items-center space-x-4">
{hasPermission(session.user!, 'membership.view') && (
<Link
href="/dashboard/membership"
className="flex items-center text-gray-300 hover:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M15 21a6 6 0 00-9-5.197m0 0A5.975 5.975 0 0112 13a5.975 5.975 0 01-3-1.197"
/>
</svg>
<span className="ml-1 hidden sm:inline">Users</span>
</Link>
)}
<LogoutButton />
</div>
</div>
</header>
<SteamCMDNotification /> <SteamCMDNotification />
<ServerListWithActions servers={servers} user={session.user!} />
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8"> </main>
<ServerListWithActions servers={servers} user={session.user!} />
</main>
</div>
); );
} }

21
src/app/dashboard/server/[id]/page.tsx Normal file → Executable file
View File

@@ -28,19 +28,14 @@ export default async function ServerPage({ params }: ServerPageProps) {
]); ]);
return ( return (
<div className="min-h-screen bg-gray-900 text-white"> <div className="flex flex-col overflow-hidden h-screen">
<div className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8"> <ServerHeader server={server} user={session.user!} />
<ServerHeader server={server} user={session.user!} /> <ServerConfigurationTabs
serverId={id}
<div className="mt-8"> configurations={configurations}
<ServerConfigurationTabs statistics={statistics}
serverId={id} leaderboard={leaderboard}
configurations={configurations} />
statistics={statistics}
leaderboard={leaderboard}
/>
</div>
</div>
</div> </div>
); );
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 361 KiB

30
src/app/globals.css Normal file → Executable file
View File

@@ -1,11 +1,35 @@
@import "tailwindcss"; @import "tailwindcss";
@theme {
--color-base: #0d1117;
--color-canvas: #161b22;
--color-overlay: #21262d;
--color-sidebar: #010409;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-hover: #1c2128;
--color-primary: #e6edf3;
--color-secondary: #c9d1d9;
--color-muted: #8b949e;
--color-subtle: #6e7681;
--color-blue: #58a6ff;
--color-blue-bg: #1f6feb33;
--color-green: #3fb950;
--color-green-bg: #3fb95022;
--color-yellow: #d29922;
--color-yellow-bg: #d2992220;
--color-red: #f85149;
--color-red-bg: #f8514920;
--color-orange: #f78166;
--color-btn-green: #238636;
--color-btn-green-hover: #2ea043;
}
@layer components { @layer components {
.form-input { .form-input {
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500; @apply block w-full rounded-md border border-border bg-overlay px-3 py-2 text-sm text-primary placeholder-subtle focus:border-blue focus:outline-none focus:ring-1 focus:ring-blue;
} }
.form-select { .form-select {
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500; @apply block w-full rounded-md border border-border bg-overlay px-3 py-2 text-sm text-primary focus:border-blue focus:outline-none focus:ring-1 focus:ring-blue;
} }
} }

2
src/app/layout.tsx Normal file → Executable file
View File

@@ -13,7 +13,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className="bg-gray-900 text-white antialiased">{children}</body> <body className="bg-base text-primary antialiased">{children}</body>
</html> </html>
); );
} }

7
src/app/login/page.tsx Normal file → Executable file
View File

@@ -1,15 +1,18 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import LoginForm from '@/components/login/LoginForm'; import LoginForm from '@/components/login/LoginForm';
import { checkHealth } from '@/lib/api/server/health';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default function LoginPage({ export default async function LoginPage({
searchParams searchParams
}: { }: {
searchParams: Promise<{ expired: boolean | undefined }>; searchParams: Promise<{ expired: boolean | undefined }>;
}) { }) {
const health = await checkHealth();
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<LoginForm searchParams={searchParams} /> <LoginForm searchParams={searchParams} health={health} />
</Suspense> </Suspense>
); );
} }

0
src/app/page.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,256 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { getActivityLogAction } from '@/lib/actions/activityLog';
import type { ActivityLog, ActivityLogFilter, ActionType } from '@/lib/schemas/activityLog';
import { ACTION_TYPE_LABELS, actionTypeSchema } from '@/lib/schemas/activityLog';
import { Button } from '@/components/ui/Button';
const PAGE_SIZE = 10;
interface ServerOption {
id: string;
name: string;
}
interface ActivityLogTableProps {
serverId?: string;
servers?: ServerOption[];
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function parseDetails(raw: string): string {
try {
const obj = JSON.parse(raw) as Record<string, unknown>;
if (obj.file && Array.isArray(obj.changed_keys)) {
return `Updated ${obj.file} — changed: ${(obj.changed_keys as string[]).join(', ')}`;
}
if (obj.service) {
return `Service: ${obj.service}`;
}
if (obj.driver_count !== undefined && obj.race_count !== undefined) {
return `${obj.driver_count} drivers, ${obj.race_count} races`;
}
return raw;
} catch {
return raw;
}
}
const ACTION_BADGE: Record<ActionType, string> = {
config_update: 'bg-blue-bg text-blue',
server_start: 'bg-green-bg text-green',
server_stop: 'bg-red-bg text-red',
server_restart: 'bg-yellow-bg text-yellow',
leaderboard_update: 'bg-overlay text-muted'
};
export function ActivityLogTable({ serverId, servers }: ActivityLogTableProps) {
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterAction, setFilterAction] = useState<ActionType | ''>('');
const [filterServerId, setFilterServerId] = useState('');
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [appliedFilter, setAppliedFilter] = useState<ActivityLogFilter>({});
const fetchLogs = useCallback(
async (currentPage: number, filter: ActivityLogFilter, reset: boolean) => {
setIsLoading(true);
setError(null);
const params = { ...filter, page: currentPage, page_size: PAGE_SIZE };
if (serverId) params.server_id = serverId;
const result = await getActivityLogAction(params);
setIsLoading(false);
if (!result.success) {
setError(result.message);
return;
}
setHasMore(result.data.length === PAGE_SIZE);
setLogs((prev) => (reset ? result.data : [...prev, ...result.data]));
},
[serverId]
);
useEffect(() => {
fetchLogs(1, {}, true);
}, [fetchLogs]);
const applyFilters = () => {
const filter: ActivityLogFilter = {};
if (filterAction) filter.action = filterAction as ActionType;
if (filterServerId) filter.server_id = filterServerId;
if (filterStartDate) filter.start_date = new Date(filterStartDate).toISOString();
if (filterEndDate) filter.end_date = new Date(filterEndDate).toISOString();
setAppliedFilter(filter);
setPage(1);
setLogs([]);
fetchLogs(1, filter, true);
};
const clearFilters = () => {
setFilterAction('');
setFilterServerId('');
setFilterStartDate('');
setFilterEndDate('');
setAppliedFilter({});
setPage(1);
setLogs([]);
fetchLogs(1, {}, true);
};
const loadMore = () => {
const next = page + 1;
setPage(next);
fetchLogs(next, appliedFilter, false);
};
return (
<div className="flex flex-col gap-4">
<div className="border-border bg-canvas flex flex-wrap items-end gap-3 rounded-lg border p-4">
<div className="flex flex-col gap-1">
<label className="text-subtle text-xs">Action type</label>
<select
value={filterAction}
onChange={(e) => setFilterAction(e.target.value as ActionType | '')}
className="form-input w-44 text-sm"
>
<option value="">All actions</option>
{actionTypeSchema.options.map((opt) => (
<option key={opt} value={opt}>
{ACTION_TYPE_LABELS[opt]}
</option>
))}
</select>
</div>
{servers && servers.length > 0 && (
<div className="flex flex-col gap-1">
<label className="text-subtle text-xs">Server</label>
<select
value={filterServerId}
onChange={(e) => setFilterServerId(e.target.value)}
className="form-input w-48 text-sm"
>
<option value="">All servers</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-subtle text-xs">From</label>
<input
type="date"
value={filterStartDate}
onChange={(e) => setFilterStartDate(e.target.value)}
className="form-input w-36 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-subtle text-xs">To</label>
<input
type="date"
value={filterEndDate}
onChange={(e) => setFilterEndDate(e.target.value)}
className="form-input w-36 text-sm"
/>
</div>
<Button variant="primary" size="sm" onClick={applyFilters}>
Apply
</Button>
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear
</Button>
</div>
{error && <p className="text-red text-sm">{error}</p>}
<div className="border-border bg-canvas overflow-hidden rounded-lg border">
{logs.length === 0 && !isLoading ? (
<div className="px-4 py-12 text-center">
<div className="mb-3 text-4xl">📋</div>
<p className="text-muted text-sm">No activity logged yet.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-border-muted bg-base border-b">
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
Time
</th>
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
User
</th>
{!serverId && (
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
Server
</th>
)}
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
Action
</th>
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
Details
</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr
key={log.id}
className="border-border-muted hover:bg-hover border-b transition-colors last:border-0"
>
<td className="text-muted px-4 py-3 text-sm whitespace-nowrap">
{formatDate(log.createdAt)}
</td>
<td className="text-secondary px-4 py-3 text-sm">{log.username}</td>
{!serverId && (
<td className="text-secondary px-4 py-3 text-sm">
{log.server?.name ?? log.serverId}
</td>
)}
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_BADGE[log.action] ?? 'bg-canvas text-muted'}`}
>
{ACTION_TYPE_LABELS[log.action] ?? log.action}
</span>
</td>
<td className="text-muted px-4 py-3 text-sm">{parseDetails(log.details)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isLoading && <div className="text-muted px-4 py-4 text-center text-sm">Loading…</div>}
{hasMore && !isLoading && logs.length > 0 && (
<div className="border-border-muted border-t px-4 py-3 text-center">
<Button variant="ghost" size="sm" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
</div>
);
}

75
src/components/configuration/AssistRulesEditor.tsx Normal file → Executable file
View File

@@ -1,12 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { AssistRules } from '@/lib/schemas/config'; import type { AssistRules } from '@/lib/schemas/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps { interface AssistRulesEditorProps {
serverId: string; formData: AssistRules;
config: AssistRules; disabled?: boolean;
onFormDataChange: (data: AssistRules) => void;
} }
const assistFields = [ const assistFields = [
@@ -57,50 +56,24 @@ const assistFields = [
} }
]; ];
export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) { export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
const [formData, setFormData] = useState<AssistRules>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateAssistRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update assist rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof AssistRules, value: string | number) => { const handleInputChange = (key: keyof AssistRules, value: string | number) => {
setFormData((prev) => ({ onFormDataChange({
...prev, ...formData,
[key]: typeof value === 'string' ? parseInt(value) : value [key]: typeof value === 'string' ? parseInt(value) : value
})); });
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => ( {assistFields.map(({ key, label, type }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
{type === 'number' ? ( {type === 'number' ? (
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key]} value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -109,10 +82,10 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps)
/> />
) : ( ) : (
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key]} value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>Allowed</option> <option value={0}>Allowed</option>
<option value={1}>Disabled</option> <option value={1}>Disabled</option>
@@ -121,28 +94,6 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps)
</div> </div>
))} ))}
</div> </div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

91
src/components/configuration/ConfigurationEditor.tsx Normal file → Executable file
View File

@@ -1,56 +1,29 @@
'use client'; 'use client';
import { useState } from 'react';
import type { Configuration } from '@/lib/schemas/config'; import type { Configuration } from '@/lib/schemas/config';
import { updateConfigurationAction } from '@/lib/actions/configuration';
interface ConfigurationEditorProps { interface ConfigurationEditorProps {
serverId: string; formData: Configuration;
config: Configuration; disabled?: boolean;
onFormDataChange: (data: Configuration) => void;
} }
export function ConfigurationEditor({ serverId, config }: ConfigurationEditorProps) { export function ConfigurationEditor({ formData, disabled, onFormDataChange }: ConfigurationEditorProps) {
const [formData, setFormData] = useState<Configuration>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateConfigurationAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update configuration:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof Configuration, value: string | number) => { const handleInputChange = (key: keyof Configuration, value: string | number) => {
setFormData((prev) => ({ onFormDataChange({
...prev, ...formData,
[key]: typeof value === 'string' ? parseInt(value) : value [key]: typeof value === 'string' ? parseInt(value) : value
})); });
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">UDP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.udpPort} value={formData.udpPort}
onChange={(e) => handleInputChange('udpPort', e.target.value)} onChange={(e) => handleInputChange('udpPort', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -58,10 +31,10 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">TCP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.tcpPort} value={formData.tcpPort}
onChange={(e) => handleInputChange('tcpPort', e.target.value)} onChange={(e) => handleInputChange('tcpPort', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -69,10 +42,10 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Max Connections</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.maxConnections} value={formData.maxConnections}
onChange={(e) => handleInputChange('maxConnections', e.target.value)} onChange={(e) => handleInputChange('maxConnections', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -80,12 +53,12 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">LAN Discovery</label> <label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.lanDiscovery} value={formData.lanDiscovery}
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)} onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>No</option> <option value={0}>No</option>
<option value={1}>Yes</option> <option value={1}>Yes</option>
@@ -93,40 +66,18 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Register To Lobby</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.registerToLobby} value={formData.registerToLobby}
onChange={(e) => handleInputChange('registerToLobby', e.target.value)} onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>No</option> <option value={0}>No</option>
<option value={1}>Yes</option> <option value={1}>Yes</option>
</select> </select>
</div> </div>
</div> </div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

215
src/components/configuration/EventConfigEditor.tsx Normal file → Executable file
View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useState } from 'react';
import type { EventConfig, Session } from '@/lib/schemas/config'; import type { EventConfig, Session } from '@/lib/schemas/config';
import { updateEventConfigAction } from '@/lib/actions/configuration'; import { TRACKS } from '@/lib/constants/tracks';
interface EventConfigEditorProps { interface EventConfigEditorProps {
serverId: string; formData: EventConfig;
config: EventConfig; disabled?: boolean;
onFormDataChange: (data: EventConfig) => void;
} }
const sessionTypes = [ const sessionTypes = [
@@ -15,49 +15,19 @@ const sessionTypes = [
{ value: 'R', label: 'Race' } { value: 'R', label: 'Race' }
]; ];
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) { export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) {
const [formData, setFormData] = useState<EventConfig>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (key === 'sessions') {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value.toString());
}
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventConfigAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event config:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventConfig, value: string | number) => { const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return; if (key === 'sessions') return;
setFormData((prev) => ({ onFormDataChange({
...prev, ...formData,
[key]: [key]:
typeof formData[key] === 'number' typeof formData[key] === 'number'
? typeof value === 'string' ? typeof value === 'string'
? parseFloat(value) || 0 ? parseFloat(value) || 0
: value : value
: value : value
})); });
}; };
const handleSessionChange = (index: number, field: keyof Session, value: string | number) => { const handleSessionChange = (index: number, field: keyof Session, value: string | number) => {
@@ -67,11 +37,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
[field]: [field]:
field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value
}; };
onFormDataChange({ ...formData, sessions: newSessions });
setFormData((prev) => ({
...prev,
sessions: newSessions
}));
}; };
const addSession = () => { const addSession = () => {
@@ -82,84 +48,57 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
sessionType: 'P', sessionType: 'P',
sessionDurationMinutes: 20 sessionDurationMinutes: 20
}; };
onFormDataChange({ ...formData, sessions: [...formData.sessions, newSession] });
setFormData((prev) => ({
...prev,
sessions: [...prev.sessions, newSession]
}));
}; };
const removeSession = (index: number) => { const removeSession = (index: number) => {
setFormData((prev) => ({ onFormDataChange({ ...formData, sessions: formData.sessions.filter((_, i) => i !== index) });
...prev,
sessions: prev.sessions.filter((_, i) => i !== index)
}));
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Event Settings Basic Event Settings
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Track</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Track</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.track} value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)} onChange={(e) => handleInputChange('track', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value="barcelona">Barcelona</option> {Object.entries(TRACKS).map(([key, name]) => (
<option value="brands_hatch">Brands Hatch</option> <option key={key} value={key}>
<option value="donington">Donington</option> {name}
<option value="cota">COTA</option> </option>
<option value="hungaroring">Hungaroring</option> ))}
<option value="imola">Imola</option>
<option value="indianapolis">Indianapolis</option>
<option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option>
<option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option>
<option value="nurburgring_24h">Nurburgring 24h</option>
<option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option>
<option value="red_bull_ring">Red Bull Ring</option>
<option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option>
<option value="valencia">Valencia</option>
<option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</option>
<option value="watkins_glen">Watkins Glen</option>
</select> </select>
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Pre-Race Waiting Time (seconds) Pre-Race Waiting Time (seconds)
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.preRaceWaitingTimeSeconds} value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)} onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
min="0" min="30"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Session Over Time (seconds) Session Over Time (seconds)
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.sessionOverTimeSeconds} value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)} onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -168,12 +107,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Post Qualify Seconds Post Qualify Seconds
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.postQualySeconds} value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)} onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -182,12 +121,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Post Race Seconds Post Race Seconds
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.postRaceSeconds} value={formData.postRaceSeconds}
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)} onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -198,17 +137,17 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Weather Settings Weather Settings
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Ambient Temperature (°C) Ambient Temperature (°C)
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.ambientTemp} value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)} onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -218,12 +157,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Cloud Level (0.0-1.0) Cloud Level (0.0-1.0)
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.cloudLevel} value={formData.cloudLevel}
onChange={(e) => handleInputChange('cloudLevel', e.target.value)} onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -234,10 +173,10 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Rain (0.0-1.0)</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.rain} value={formData.rain}
onChange={(e) => handleInputChange('rain', e.target.value)} onChange={(e) => handleInputChange('rain', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -248,12 +187,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Weather Randomness Weather Randomness
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.weatherRandomness} value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)} onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -263,14 +202,14 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Simracer Weather Conditions Simracer Weather Conditions
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.simracerWeatherConditions} value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)} onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>No</option> <option value={0}>No</option>
<option value={1}>Yes</option> <option value={1}>Yes</option>
@@ -278,14 +217,14 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Fixed Condition Qualification Fixed Condition Qualification
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.isFixedConditionQualification} value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)} onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>No</option> <option value={0}>No</option>
<option value={1}>Yes</option> <option value={1}>Yes</option>
@@ -295,28 +234,28 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between border-b border-gray-700 pb-2"> <div className="flex items-center justify-between border-b border-border-muted pb-2">
<h3 className="text-lg font-medium text-white">Sessions</h3> <h3 className="text-sm font-semibold text-primary">Sessions</h3>
<button <button
type="button" type="button"
onClick={addSession} onClick={addSession}
disabled={isSubmitting} disabled={disabled}
className="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" className="rounded-md border border-border bg-overlay px-3 py-1.5 text-xs font-medium text-secondary hover:bg-border disabled:opacity-40"
> >
Add Session + Add Session
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{formData.sessions.map((session, index) => ( {formData.sessions.map((session, index) => (
<div key={index} className="rounded-lg bg-gray-800 p-4"> <div key={index} className="rounded-lg border border-border bg-canvas p-4">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-medium text-white">Session {index + 1}</h4> <h4 className="text-sm font-medium text-primary">Session {index + 1}</h4>
<button <button
type="button" type="button"
onClick={() => removeSession(index)} onClick={() => removeSession(index)}
disabled={isSubmitting} disabled={disabled}
className="rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50" className="rounded px-2 py-1 text-xs text-red hover:bg-red-bg disabled:opacity-40"
> >
Remove Remove
</button> </button>
@@ -324,14 +263,14 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-muted">
Session Type Session Type
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={session.sessionType} value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)} onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-input w-full text-sm" className="form-select w-full text-sm"
> >
{sessionTypes.map((type) => ( {sessionTypes.map((type) => (
<option key={type.value} value={type.value}> <option key={type.value} value={type.value}>
@@ -342,12 +281,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-muted">
Hour of Day Hour of Day
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.hourOfDay} value={session.hourOfDay}
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)} onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -357,12 +296,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-muted">
Day of Weekend Day of Weekend
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.dayOfWeekend} value={session.dayOfWeekend}
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)} onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -372,12 +311,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-muted">
Time Multiplier Time Multiplier
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.timeMultiplier} value={session.timeMultiplier}
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)} onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -387,12 +326,12 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-400"> <label className="mb-1 block text-xs font-medium text-muted">
Duration (minutes) Duration (minutes)
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.sessionDurationMinutes} value={session.sessionDurationMinutes}
onChange={(e) => onChange={(e) =>
handleSessionChange(index, 'sessionDurationMinutes', e.target.value) handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
@@ -406,28 +345,6 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
))} ))}
</div> </div>
</div> </div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

110
src/components/configuration/EventRulesEditor.tsx Normal file → Executable file
View File

@@ -1,21 +1,14 @@
'use client'; 'use client';
import { useState } from 'react';
import type { EventRules } from '@/lib/schemas/config'; import type { EventRules } from '@/lib/schemas/config';
import { updateEventRulesAction } from '@/lib/actions/configuration';
interface EventRulesEditorProps { interface EventRulesEditorProps {
serverId: string; formData: EventRules;
config: EventRules; disabled?: boolean;
onFormDataChange: (data: EventRules) => void;
} }
const numberFields = [ const numberFields = [
{
key: 'qualifyStandingType' as keyof EventRules,
label: 'Qualify Standing Type',
min: -1,
max: 1
},
{ {
key: 'pitWindowLengthSec' as keyof EventRules, key: 'pitWindowLengthSec' as keyof EventRules,
label: 'Pit Window Length (seconds)', label: 'Pit Window Length (seconds)',
@@ -29,7 +22,7 @@ const numberFields = [
{ {
key: 'mandatoryPitstopCount' as keyof EventRules, key: 'mandatoryPitstopCount' as keyof EventRules,
label: 'Mandatory Pitstop Count', label: 'Mandatory Pitstop Count',
min: -1, min: 0,
max: 5 max: 5
}, },
{ {
@@ -37,6 +30,12 @@ const numberFields = [
label: 'Max Total Driving Time (seconds)', label: 'Max Total Driving Time (seconds)',
min: -1 min: -1
}, },
{
key: 'maxDriversCount' as keyof EventRules,
label: 'Max Drivers Count',
min: 1,
max: 10
},
{ {
key: 'tyreSetCount' as keyof EventRules, key: 'tyreSetCount' as keyof EventRules,
label: 'Tyre Set Count', label: 'Tyre Set Count',
@@ -68,51 +67,34 @@ const booleanFields = [
} }
]; ];
export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) { export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
const [formData, setFormData] = useState<EventRules>(config); const handleInputChange = (key: keyof EventRules, value: number) => {
const [restart, setRestart] = useState(true); onFormDataChange({ ...formData, [key]: value });
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventRules, value: string | number | boolean) => {
setFormData((prev) => ({
...prev,
[key]: value
}));
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">Race Rules</h3> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Qualify Standing Type</label>
<select
disabled={disabled}
value={formData.qualifyStandingType}
onChange={(e) => handleInputChange('qualifyStandingType', parseInt(e.target.value))}
className="form-select w-full"
>
<option value={1}>Fastest Lap</option>
<option value={2}>Average Lap</option>
</select>
</div>
{numberFields.map(({ key, label, min, max }) => ( {numberFields.map(({ key, label, min, max }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)} onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full" className="form-input w-full"
@@ -125,18 +107,18 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Pitstop & Refuelling Rules Pitstop & Refuelling Rules
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{booleanFields.map(({ key, label }) => ( {booleanFields.map(({ key, label }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key] ? 'true' : 'false'} value={formData[key] ? 'true' : 'false'}
onChange={(e) => handleInputChange(key, e.target.value === 'true')} onChange={(e) => handleInputChange(key, e.target.value === 'true' ? 1 : 0)}
className="form-input w-full" className="form-select w-full"
> >
<option value="false">No</option> <option value="false">No</option>
<option value="true">Yes</option> <option value="true">Yes</option>
@@ -145,28 +127,6 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
))} ))}
</div> </div>
</div> </div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

104
src/components/configuration/ServerSettingsEditor.tsx Normal file → Executable file
View File

@@ -1,12 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { ServerSettings } from '@/lib/schemas/config'; import type { ServerSettings } from '@/lib/schemas/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps { interface ServerSettingsEditorProps {
serverId: string; formData: ServerSettings;
config: ServerSettings; disabled?: boolean;
onFormDataChange: (data: ServerSettings) => void;
} }
const textFields = [ const textFields = [
@@ -43,7 +42,7 @@ const numberFields = [
{ {
key: 'trackMedalsRequirement' as keyof ServerSettings, key: 'trackMedalsRequirement' as keyof ServerSettings,
label: 'Track Medals Requirement', label: 'Track Medals Requirement',
min: -1, min: 0,
max: 3 max: 3
}, },
{ {
@@ -81,59 +80,34 @@ const selectFields = [
key: 'shortFormationLap' as keyof ServerSettings, key: 'shortFormationLap' as keyof ServerSettings,
label: 'Short Formation Lap' label: 'Short Formation Lap'
}, },
{
key: 'dumpEntryList' as keyof ServerSettings,
label: 'Dump Entry List'
},
{ {
key: 'ignorePrematureDisconnects' as keyof ServerSettings, key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects' label: 'Ignore Premature Disconnects'
} }
]; ];
export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) { export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
const [formData, setFormData] = useState<ServerSettings>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateServerSettingsAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update server settings:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof ServerSettings, value: string | number) => { const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
setFormData((prev) => ({ onFormDataChange({ ...formData, [key]: value });
...prev,
[key]: value
}));
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Settings Basic Settings
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{textFields.map(({ key, label, type }) => ( {textFields.map(({ key, label, type }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type={type} type={type}
disabled={isSubmitting} disabled={disabled}
value={formData[key] as string} value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -142,12 +116,12 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
))} ))}
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300">Car Group</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.carGroup} value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)} onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-input w-full" className="form-select w-full"
> >
{carGroups.map((group) => ( {carGroups.map((group) => (
<option key={group} value={group}> <option key={group} value={group}>
@@ -160,16 +134,16 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Requirements & Limits Requirements & Limits
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{numberFields.map(({ key, label, min, max }) => ( {numberFields.map(({ key, label, min, max }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)} onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full" className="form-input w-full"
@@ -182,18 +156,18 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Race Options Race Options
</h3> </h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{selectFields.map(({ key, label }) => ( {selectFields.map(({ key, label }) => (
<div key={key}> <div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))} onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>No</option> <option value={0}>No</option>
<option value={1}>Yes</option> <option value={1}>Yes</option>
@@ -202,14 +176,14 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
))} ))}
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-300"> <label className="mb-1.5 block text-sm font-medium text-secondary">
Formation Lap Type Formation Lap Type
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.formationLapType} value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))} onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-input w-full" className="form-select w-full"
> >
<option value={0}>Old Limiter Lap</option> <option value={0}>Old Limiter Lap</option>
<option value={1}> <option value={1}>
@@ -220,28 +194,6 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

25
src/components/leaderboard/LeaderboardManager.tsx Normal file → Executable file
View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Leaderboard, LeaderboardDriver, FlPoints } from '@/lib/schemas/leaderboard'; import { Leaderboard, LeaderboardDriver, FlPoints } from '@/lib/schemas/leaderboard';
import { saveLeaderboardAction } from '@/lib/actions/leaderboard'; import { saveLeaderboardAction } from '@/lib/actions/leaderboard';
import { LeaderboardStandings } from './LeaderboardStandings'; import { LeaderboardStandings } from './LeaderboardStandings';
@@ -135,19 +136,19 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{dirty && ( {dirty && (
<div className="flex items-center justify-between rounded-lg bg-yellow-900/40 px-4 py-2 text-sm text-yellow-300"> <div className="flex items-center justify-between rounded-lg border border-yellow/30 bg-yellow-bg px-4 py-2 text-sm text-yellow">
<span>Unsaved changes</span> <span>Unsaved changes</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setDraft(saved)} onClick={() => setDraft(saved)}
className="rounded px-2 py-1 text-xs text-yellow-400 hover:text-yellow-200" className="rounded px-2 py-1 text-xs text-yellow hover:text-primary"
> >
Discard Discard
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSubmitting} disabled={isSubmitting}
className="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-500 disabled:opacity-50" className="rounded bg-yellow px-3 py-1 text-xs font-semibold text-base hover:opacity-90 disabled:opacity-40"
> >
{isSubmitting ? 'Saving…' : 'Save'} {isSubmitting ? 'Saving…' : 'Save'}
</button> </button>
@@ -156,7 +157,9 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
)} )}
{error && ( {error && (
<div className="rounded-lg bg-red-900/40 px-4 py-2 text-sm text-red-300">{error}</div> <div className="rounded-lg border border-red/30 bg-red-bg px-4 py-2 text-sm text-red">
{error}
</div>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -164,10 +167,10 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
<button <button
key={s.id} key={s.id}
onClick={() => setActiveSection(s.id)} onClick={() => setActiveSection(s.id)}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeSection === s.id activeSection === s.id
? 'bg-blue-600 text-white' ? 'bg-blue text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-overlay text-secondary hover:bg-border'
}`} }`}
> >
{s.label} {s.label}
@@ -179,13 +182,9 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
{!dirty && ( {!dirty && (
<div className="flex justify-end"> <div className="flex justify-end">
<button <Button variant="primary" size="md" onClick={handleSave} disabled={isSubmitting}>
onClick={handleSave}
disabled={isSubmitting}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
>
{isSubmitting ? 'Saving…' : 'Save Leaderboard'} {isSubmitting ? 'Saving…' : 'Save Leaderboard'}
</button> </Button>
</div> </div>
)} )}
</div> </div>

0
src/components/leaderboard/LeaderboardStandings.tsx Normal file → Executable file
View File

0
src/components/leaderboard/editors/DriversEditor.tsx Normal file → Executable file
View File

0
src/components/leaderboard/editors/FlPointsEditor.tsx Normal file → Executable file
View File

View File

6
src/components/leaderboard/editors/TracksEditor.tsx Normal file → Executable file
View File

@@ -75,7 +75,7 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: `Race ${tracks.length + 1}`, name: `Race ${tracks.length + 1}`,
results: drivers.map((d) => ({ driverId: d.id, score: 'DNS' as const })), results: drivers.map((d) => ({ driverId: d.id, score: 'DNS' as const })),
fastestLapDriverId: '' fastestLapDriverId: null
} }
]); ]);
}; };
@@ -95,8 +95,8 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-400">FL Driver:</label> <label className="text-xs text-gray-400">FL Driver:</label>
<select <select
value={track.fastestLapDriverId} value={track.fastestLapDriverId ?? ''}
onChange={(e) => updateTrack(ti, { fastestLapDriverId: e.target.value })} onChange={(e) => updateTrack(ti, { fastestLapDriverId: e.target.value || null })}
className="rounded bg-gray-600 px-2 py-1 text-sm text-white" className="rounded bg-gray-600 px-2 py-1 text-sm text-white"
> >
<option value="">—</option> <option value="">—</option>

37
src/components/login/LoginForm.tsx Normal file → Executable file
View File

@@ -2,6 +2,8 @@
import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth'; import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth';
import { use, useActionState, useEffect } from 'react'; import { use, useActionState, useEffect } from 'react';
import type { HealthStatus } from '@/lib/api/server/health';
import { Badge } from '@/components/ui/Badge';
const initialState: LoginResult = { const initialState: LoginResult = {
message: '', message: '',
@@ -9,9 +11,11 @@ const initialState: LoginResult = {
}; };
export default function LoginForm({ export default function LoginForm({
searchParams searchParams,
health
}: { }: {
searchParams: Promise<{ expired: boolean | undefined }>; searchParams: Promise<{ expired: boolean | undefined }>;
health: HealthStatus;
}) { }) {
const params = use(searchParams); const params = use(searchParams);
const expired = params.expired; const expired = params.expired;
@@ -23,26 +27,33 @@ export default function LoginForm({
}, [expired]); }, [expired]);
const [state, formAction] = useActionState(loginAction, initialState); const [state, formAction] = useActionState(loginAction, initialState);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4"> <div className="flex min-h-screen items-center justify-center bg-base px-4">
<div className="w-full max-w-md space-y-8 rounded-lg bg-gray-800 p-8 shadow-lg"> <div className="w-full max-w-md space-y-6 rounded-lg border border-border bg-canvas p-8 shadow-xl">
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold text-white">ACC Server Manager</h1> <h1 className="text-xl font-bold text-primary">ACC Server Manager</h1>
<p className="mt-2 text-gray-400">Sign in to manage your servers</p> <p className="mt-1 text-sm text-muted">Sign in to manage your servers</p>
<div className="mt-2 flex justify-center">
{health.healthy ? (
<Badge variant="green">API v{health.version}</Badge>
) : (
<Badge variant="red">API unreachable</Badge>
)}
</div>
</div> </div>
{expired && ( {expired && (
<div className="rounded-md border border-yellow-700 bg-yellow-900/50 p-3 text-sm text-yellow-200"> <div className="rounded-md border border-yellow/30 bg-yellow-bg px-3 py-2 text-sm text-yellow">
Your session has expired. Please sign in again. Your session has expired. Please sign in again.
</div> </div>
)} )}
{state?.success ? null : ( {state?.success ? null : (
<div className="rounded-md border border-red-700 bg-red-900/50 p-3 text-sm text-red-200"> <div className="rounded-md border border-red/30 bg-red-bg px-3 py-2 text-sm text-red">
{state?.message} {state?.message}
</div> </div>
)} )}
<form action={formAction} className="space-y-6"> <form action={formAction} className="space-y-4">
<div> <div>
<label htmlFor="username" className="mb-2 block text-sm font-medium text-gray-300"> <label htmlFor="username" className="mb-1.5 block text-sm font-medium text-secondary">
Username Username
</label> </label>
<input <input
@@ -51,12 +62,12 @@ export default function LoginForm({
type="text" type="text"
autoComplete="username" autoComplete="username"
required required
className="form-input w-full" className="form-input"
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-gray-300"> <label htmlFor="password" className="mb-1.5 block text-sm font-medium text-secondary">
Password Password
</label> </label>
<input <input
@@ -65,13 +76,13 @@ export default function LoginForm({
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
className="form-input w-full" className="form-input"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-3 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-none" className="w-full rounded-md bg-btn-green border border-btn-green px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover focus:outline-none focus:ring-2 focus:ring-blue focus:ring-offset-2 focus:ring-offset-base"
> >
Sign in Sign in
</button> </button>

51
src/components/membership/CreateUserModal.tsx Normal file → Executable file
View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Role } from '@/lib/schemas'; import type { Role } from '@/lib/schemas';
import { createUserAction } from '@/lib/actions/membership'; import { createUserAction } from '@/lib/actions/membership';
import { Button } from '@/components/ui/Button';
interface CreateUserModalProps { interface CreateUserModalProps {
roles: Role[]; roles: Role[];
@@ -44,17 +45,22 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
}; };
return ( return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6"> <div className="w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl">
<h3 className="mb-4 text-lg font-semibold text-white">Create New User</h3> <h2 className="mb-4 text-base font-semibold text-primary">Create New User</h2>
{error && ( {error && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div> <div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">
{error}
</div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="create-username" className="block text-sm font-medium text-gray-300"> <label
htmlFor="create-username"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Username Username
</label> </label>
<input <input
@@ -64,12 +70,15 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
required required
disabled={isSubmitting} disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50" className="form-input"
/> />
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="create-password" className="block text-sm font-medium text-gray-300"> <label
htmlFor="create-password"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Password Password
</label> </label>
<input <input
@@ -79,12 +88,15 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
required required
disabled={isSubmitting} disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50" className="form-input"
/> />
</div> </div>
<div className="mb-6"> <div className="mb-6">
<label htmlFor="create-role" className="block text-sm font-medium text-gray-300"> <label
htmlFor="create-role"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Role Role
</label> </label>
<select <select
@@ -93,7 +105,7 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))}
required required
disabled={isSubmitting} disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50" className="form-input"
> >
<option value="">Select a role...</option> <option value="">Select a role...</option>
{roles.map((role) => ( {roles.map((role) => (
@@ -104,22 +116,13 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
</select> </select>
</div> </div>
<div className="flex justify-end space-x-2"> <div className="flex justify-end gap-2">
<button <Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
type="button"
onClick={onClose}
disabled={isSubmitting}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
>
Cancel Cancel
</button> </Button>
<button <Button type="submit" variant="primary" disabled={isSubmitting}>
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create User'} {isSubmitting ? 'Creating...' : 'Create User'}
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

32
src/components/membership/DeleteUserModal.tsx Normal file → Executable file
View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { User } from '@/lib/schemas'; import type { User } from '@/lib/schemas';
import { deleteUserAction } from '@/lib/actions/membership'; import { deleteUserAction } from '@/lib/actions/membership';
import { Button } from '@/components/ui/Button';
interface DeleteUserModalProps { interface DeleteUserModalProps {
user: User; user: User;
@@ -37,36 +38,29 @@ export function DeleteUserModal({ user, onClose }: DeleteUserModalProps) {
}; };
return ( return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6"> <div className="w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl">
<h3 className="mb-4 text-lg font-semibold text-white">Delete User</h3> <h2 className="mb-4 text-base font-semibold text-primary">Delete User</h2>
{error && ( {error && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div> <div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">
{error}
</div>
)} )}
<p className="mb-6 text-gray-300"> <p className="mb-6 text-sm text-secondary">
Are you sure you want to delete the user &quot;{user.username}&quot;? This action cannot Are you sure you want to delete the user &quot;{user.username}&quot;? This action cannot
be undone. be undone.
</p> </p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="flex justify-end space-x-2"> <div className="flex justify-end gap-2">
<button <Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
type="button"
onClick={onClose}
disabled={isSubmitting}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
>
Cancel Cancel
</button> </Button>
<button <Button type="submit" variant="danger-solid" disabled={isSubmitting}>
type="submit"
disabled={isSubmitting}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-red-700 disabled:opacity-50"
>
{isSubmitting ? 'Deleting...' : 'Delete User'} {isSubmitting ? 'Deleting...' : 'Delete User'}
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

267
src/components/membership/UserManagementTable.tsx Normal file → Executable file
View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { Role, User } from '@/lib/schemas'; import type { Role, User } from '@/lib/schemas';
import { hasPermission } from '@/lib/schemas/user'; import { hasPermission } from '@/lib/schemas/user';
import { CreateUserModal } from './CreateUserModal'; import { CreateUserModal } from './CreateUserModal';
import { DeleteUserModal } from './DeleteUserModal'; import { DeleteUserModal } from './DeleteUserModal';
import { Button } from '@/components/ui/Button';
interface UserManagementTableProps { interface UserManagementTableProps {
initialData: User[]; initialData: User[];
@@ -55,7 +55,7 @@ export function UserManagementTable({ initialData, roles, currentUser }: UserMan
}; };
const getSortIcon = (field: string) => { const getSortIcon = (field: string) => {
if (sortBy !== field) return '↕️'; if (sortBy !== field) return '↕';
return sortDesc ? '↓' : '↑'; return sortDesc ? '↓' : '↑';
}; };
@@ -79,163 +79,134 @@ export function UserManagementTable({ initialData, roles, currentUser }: UserMan
return ( return (
<> <>
<header className="bg-gray-800 shadow-md"> <header className="flex h-12 items-center justify-between border-b border-border-muted px-5">
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8"> <div className="flex items-center gap-3">
<div className="flex items-center space-x-4"> <span className="text-sm font-semibold text-primary">User Management</span>
<Link href="/dashboard" className="text-gray-300 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
</Link>
<h1 className="text-2xl font-bold">User Management</h1>
</div>
{hasPermission(currentUser, 'membership.create') && (
<button
onClick={openCreateModal}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700"
>
Create User
</button>
)}
</div> </div>
{hasPermission(currentUser, 'membership.create') && (
<Button variant="primary" size="sm" onClick={openCreateModal}>
+ Create User
</Button>
)}
</header> </header>
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8"> <div className="mx-5 mt-4 rounded-lg border border-border bg-canvas p-4">
{/* Filters */} <div className="mb-3 text-sm font-semibold text-primary">Filters</div>
<div className="mb-6 rounded-lg border border-gray-700 bg-gray-800 p-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<h2 className="mb-3 text-lg font-semibold">Filters</h2> <div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <label htmlFor="username" className="mb-1 block text-xs font-medium text-muted">
<div> Username
<label htmlFor="username" className="block text-sm font-medium text-gray-300"> </label>
Username <input
</label> id="username"
<input type="text"
id="username" value={usernameFilter}
type="text" onChange={(e) => setUsernameFilter(e.target.value)}
value={usernameFilter} placeholder="Search by username..."
onChange={(e) => setUsernameFilter(e.target.value)} className="form-input"
placeholder="Search by username..." />
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-300">
Role
</label>
<input
id="role"
type="text"
value={roleNameFilter}
onChange={(e) => setRoleNameFilter(e.target.value)}
placeholder="Filter by role..."
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div> </div>
<div className="mt-4 flex space-x-2"> <div>
<button <label htmlFor="role" className="mb-1 block text-xs font-medium text-muted">
onClick={applyFilters} Role
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-700" </label>
> <input
Apply Filters id="role"
</button> type="text"
<button value={roleNameFilter}
onClick={resetFilters} onChange={(e) => setRoleNameFilter(e.target.value)}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700" placeholder="Filter by role..."
> className="form-input"
Reset />
</button>
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2">
<Button variant="primary" size="sm" onClick={applyFilters}>
Apply
</Button>
<Button variant="ghost" size="sm" onClick={resetFilters}>
Reset
</Button>
</div>
</div>
{/* Results Summary */} <div className="mx-5 mt-3 text-xs text-muted">Showing {initialData.length} users</div>
<div className="mb-4 text-sm text-gray-400">Showing {initialData.length} users</div>
{/* Users Table */} <div className="mx-5 mt-2 mb-5 overflow-hidden rounded-lg border border-border bg-canvas">
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800"> <table className="min-w-full">
<table className="min-w-full divide-y divide-gray-700"> <thead>
<thead className="bg-gray-900"> <tr className="border-b border-border-muted bg-base">
<tr> <th className="px-4 py-2 text-left">
<th className="px-6 py-3 text-left"> <button
<button onClick={() => handleSort('username')}
className="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase transition-colors hover:text-white" className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-subtle hover:text-primary"
onClick={() => handleSort('username')} >
> Username <span>{getSortIcon('username')}</span>
<span>Username</span> </button>
<span>{getSortIcon('username')}</span> </th>
</button> <th className="px-4 py-2 text-left">
<button
onClick={() => handleSort('role')}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-subtle hover:text-primary"
>
Role <span>{getSortIcon('role')}</span>
</button>
</th>
{hasPermission(currentUser, 'membership.edit') && (
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Actions
</th> </th>
<th className="px-6 py-3 text-left">
<button
className="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase transition-colors hover:text-white"
onClick={() => handleSort('role')}
>
<span>Role</span>
<span>{getSortIcon('role')}</span>
</button>
</th>
{hasPermission(currentUser, 'membership.edit') && (
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Actions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-800">
{initialData.length > 0 ? (
initialData.map((user) => (
<tr key={user.id} className="transition-colors hover:bg-gray-700">
<td className="px-6 py-4 text-sm font-medium whitespace-nowrap text-white">
{user.username}
</td>
<td className="px-6 py-4 text-sm whitespace-nowrap">
<span className="inline-flex rounded-full bg-blue-900 px-2 py-1 text-xs font-semibold text-blue-300">
{user.role.name}
</span>
</td>
{hasPermission(currentUser, 'membership.edit') && (
<td className="px-6 py-4 text-sm font-medium whitespace-nowrap">
<div className="flex space-x-2">
<button className="text-blue-400 transition-colors hover:text-blue-300">
Edit
</button>
{hasPermission(currentUser, 'membership.delete') && (
<button
onClick={() => openDeleteModal(user)}
className="text-red-400 transition-colors hover:text-red-300"
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))
) : (
<tr>
<td colSpan={3} className="px-6 py-4 text-center text-sm text-gray-400">
No users found
</td>
</tr>
)} )}
</tbody> </tr>
</table> </thead>
</div> <tbody>
</main> {initialData.length > 0 ? (
initialData.map((user) => (
<tr
key={user.id}
className="border-b border-border-muted transition-colors hover:bg-hover last:border-0"
>
<td className="px-4 py-3 text-sm font-medium text-primary">
{user.username}
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-blue/20 bg-blue-bg px-2 py-0.5 text-xs font-medium text-blue">
{user.role.name}
</span>
</td>
{hasPermission(currentUser, 'membership.edit') && (
<td className="px-4 py-3">
<div className="flex gap-3">
<button className="text-xs text-blue hover:underline">
Edit
</button>
{hasPermission(currentUser, 'membership.delete') && (
<button
onClick={() => openDeleteModal(user)}
className="text-xs text-red hover:underline"
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))
) : (
<tr>
<td
colSpan={hasPermission(currentUser, 'membership.edit') ? 3 : 2}
className="px-4 py-8 text-center text-sm text-muted"
>
No users found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Modals */}
{showCreateModal && <CreateUserModal roles={roles} onClose={closeCreateModal} />} {showCreateModal && <CreateUserModal roles={roles} onClose={closeCreateModal} />}
{showDeleteModal && selectedUser && ( {showDeleteModal && selectedUser && (

0
src/components/providers/QueryProvider.tsx Normal file → Executable file
View File

23
src/components/server/CreateServerModal.tsx Normal file → Executable file
View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useActionState, useTransition } from 'react'; import { useState, useEffect, useActionState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Button } from '@/components/ui/Button';
import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management'; import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management';
import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext'; import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext';
@@ -58,12 +59,12 @@ export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) {
return ( return (
<Modal isOpen={isOpen} onClose={handleClose} title="Create New Server"> <Modal isOpen={isOpen} onClose={handleClose} title="Create New Server">
{!state.success && state.message && ( {!state.success && state.message && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{state.message}</div> <div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">{state.message}</div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-6"> <div className="mb-6">
<label htmlFor="server-name" className="block text-sm font-medium text-gray-300"> <label htmlFor="server-name" className="mb-1.5 block text-sm font-medium text-secondary">
Server Name Server Name
</label> </label>
<input <input
@@ -73,34 +74,36 @@ export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) {
onChange={(e) => setServerName(e.target.value)} onChange={(e) => setServerName(e.target.value)}
required required
disabled={isSubmitting || isPending} disabled={isSubmitting || isPending}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50" className="form-input"
placeholder="Enter server name..." placeholder="Enter server name..."
/> />
</div> </div>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button <Button
type="button" type="button"
variant="ghost"
size="md"
onClick={handleClose} onClick={handleClose}
disabled={isSubmitting || isPending} disabled={isSubmitting || isPending}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
> >
Cancel Cancel
</button> </Button>
<button <Button
type="submit" type="submit"
variant="primary"
size="md"
disabled={isSubmitting || !serverName.trim() || isPending} disabled={isSubmitting || !serverName.trim() || isPending}
className="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<LoadingSpinner className="mr-2 h-4 w-4" /> <LoadingSpinner size="sm" />
Creating... Creating...
</> </>
) : ( ) : (
'Create Server' 'Create Server'
)} )}
</button> </Button>
</div> </div>
</form> </form>
</Modal> </Modal>

23
src/components/server/DeleteServerModal.tsx Normal file → Executable file
View File

@@ -3,6 +3,7 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Button } from '@/components/ui/Button';
import { deleteServerAction } from '@/lib/actions/server-management'; import { deleteServerAction } from '@/lib/actions/server-management';
import { Server } from '@/lib/schemas/server'; import { Server } from '@/lib/schemas/server';
@@ -42,37 +43,39 @@ export function DeleteServerModal({ isOpen, onClose, server }: DeleteServerModal
return ( return (
<Modal isOpen={isOpen} onClose={handleClose} title="Delete Server"> <Modal isOpen={isOpen} onClose={handleClose} title="Delete Server">
{error && <div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>} {error && <div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">{error}</div>}
<div className="mb-6"> <div className="mb-6">
<p className="text-gray-300"> <p className="text-secondary">
Are you sure you want to delete the server <strong>&quot;{server.name}&quot;</strong>? Are you sure you want to delete the server <strong>&quot;{server.name}&quot;</strong>?
</p> </p>
<p className="mt-2 text-sm text-gray-400">This action cannot be undone.</p> <p className="mt-2 text-sm text-muted">This action cannot be undone.</p>
</div> </div>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button <Button
variant="ghost"
size="md"
onClick={handleClose} onClick={handleClose}
disabled={isPending} disabled={isPending}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
> >
Cancel Cancel
</button> </Button>
<button <Button
variant="danger-solid"
size="md"
onClick={handleDelete} onClick={handleDelete}
disabled={isPending} disabled={isPending}
className="flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-red-700 disabled:opacity-50"
> >
{isPending ? ( {isPending ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" />
Deleting... Deleting...
</> </>
) : ( ) : (
'Delete Server' 'Delete Server'
)} )}
</button> </Button>
</div> </div>
</Modal> </Modal>
); );

144
src/components/server/ServerCard.tsx Normal file → Executable file
View File

@@ -1,17 +1,32 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useTransition } from 'react'; import { useTransition } from 'react';
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/schemas'; import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
stopServerEventAction stopServerEventAction
} from '@/lib/actions/servers'; } from '@/lib/actions/servers';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { StatusDot } from '@/components/ui/StatusDot';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface ServerCardProps { interface ServerCardProps {
server: Server; server: Server;
} }
function statusBadgeVariant(status: ServiceStatus): 'green' | 'yellow' | 'gray' {
if (status === ServiceStatus.Running) return 'green';
if (
[ServiceStatus.Starting, ServiceStatus.Restarting, ServiceStatus.Stopping].includes(status)
)
return 'yellow';
return 'gray';
}
export function ServerCard({ server }: ServerCardProps) { export function ServerCard({ server }: ServerCardProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
@@ -36,82 +51,71 @@ export function ServerCard({ server }: ServerCardProps) {
ServiceStatus.Restarting, ServiceStatus.Restarting,
ServiceStatus.Starting, ServiceStatus.Starting,
ServiceStatus.Stopping, ServiceStatus.Stopping,
ServiceStatus.Unknown ServiceStatus.Unknown,
].includes(server.status); ].includes(server.status);
const isRunning = server.status === ServiceStatus.Running;
return ( return (
<> <div className="flex flex-col overflow-hidden rounded-lg border border-border bg-canvas transition-colors hover:border-blue/40">
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg"> <Link href={`/dashboard/server/${server.id}`} className="flex flex-1 flex-col p-4">
<Link href={`/dashboard/server/${server.id}`} className="block"> <div className="flex items-start justify-between">
<div className="p-6"> <span className="text-sm font-semibold text-primary">{server.name}</span>
<div className="flex items-start justify-between"> <StatusDot status={server.status} />
<div> </div>
<h3 className="text-lg font-medium text-white">{server.name}</h3>
<div className="mt-2 flex items-center">
<span
className={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
/>
<span className="text-sm text-gray-300 capitalize">
{serviceStatusToString(server.status)}
</span>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-gray-400 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 text-sm text-gray-300"> <div className="mt-3 grid grid-cols-2 gap-3">
<div> <div>
<span className="text-gray-500">Track:</span> <div className="text-xs text-subtle">Players</div>
<span className="ml-2">{server.state?.track || 'N/A'}</span> <div className="text-sm font-semibold text-primary">
</div> {server.state?.playerCount ?? 0}
<div> </div>
<span className="text-gray-500">Players:</span> </div>
<span className="ml-2">{server.state?.playerCount || 0}</span> <div>
</div> <div className="text-xs text-subtle">Track</div>
<div className="truncate text-sm font-semibold text-primary">
{server.state?.track ? getTrackDisplayName(server.state.track) : 'N/A'}
</div> </div>
</div> </div>
</Link>
<div className="flex justify-between gap-2 bg-gray-900 px-4 py-3">
<button
type="button"
onClick={startServer}
disabled={server.status === ServiceStatus.Running || isPending || disabled}
className="rounded bg-green-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Start
</button>
<button
type="button"
onClick={restartServer}
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
className="rounded bg-yellow-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Restart
</button>
<button
type="button"
onClick={stopServer}
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
className="rounded bg-red-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Stop
</button>
</div> </div>
<div className="mt-auto pt-3 flex items-center justify-between gap-2">
<Badge variant={statusBadgeVariant(server.status)}>
{serviceStatusToString(server.status)}
</Badge>
{server.lastActivity && (
<span className="truncate text-xs text-subtle">
by {server.lastActivity.username}
</span>
)}
</div>
</Link>
<div className="flex gap-1.5 border-t border-border-muted bg-base px-4 py-2">
<Button
variant="primary"
size="sm"
onClick={startServer}
disabled={isRunning || disabled || isPending}
>
Start
</Button>
<Button
variant="ghost"
size="sm"
onClick={restartServer}
disabled={!isRunning || disabled || isPending}
>
Restart
</Button>
<Button
variant="danger-outline"
size="sm"
onClick={stopServer}
disabled={!isRunning || disabled || isPending}
>
Stop
</Button>
</div> </div>
</> </div>
); );
} }

182
src/components/server/ServerConfigurationTabs.tsx Normal file → Executable file
View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Configurations, ServerTab } from '@/lib/schemas/config'; import { type Configurations, ServerTab } from '@/lib/schemas/config';
import { ConfigurationEditor } from '@/components/configuration/ConfigurationEditor'; import { ConfigurationEditor } from '@/components/configuration/ConfigurationEditor';
import { AssistRulesEditor } from '@/components/configuration/AssistRulesEditor'; import { AssistRulesEditor } from '@/components/configuration/AssistRulesEditor';
import { EventConfigEditor } from '@/components/configuration/EventConfigEditor'; import { EventConfigEditor } from '@/components/configuration/EventConfigEditor';
@@ -8,9 +8,12 @@ import { EventRulesEditor } from '@/components/configuration/EventRulesEditor';
import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsEditor'; import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsEditor';
import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard'; import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard';
import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'; import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager';
import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
import { ServerLogViewer } from '@/components/server/ServerLogViewer';
import { useState } from 'react'; import { useState } from 'react';
import { StateHistoryStats } from '@/lib/schemas'; import { StateHistoryStats } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard'; import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps { interface ServerConfigurationTabsProps {
serverId: string; serverId: string;
@@ -25,9 +28,19 @@ const tabs = [
{ id: ServerTab.event, name: 'Event Config', icon: '🏁' }, { id: ServerTab.event, name: 'Event Config', icon: '🏁' },
{ id: ServerTab.eventRules, name: 'Event Rules', icon: '📋' }, { id: ServerTab.eventRules, name: 'Event Rules', icon: '📋' },
{ id: ServerTab.settings, name: 'Server Settings', icon: '🔧' }, { id: ServerTab.settings, name: 'Server Settings', icon: '🔧' },
{ id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' } { id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' },
{ id: ServerTab.activityLog, name: 'History', icon: '📋' },
{ id: ServerTab.logs, name: 'Logs', icon: '📄' }
]; ];
const configTabs = new Set([
ServerTab.configuration,
ServerTab.assistRules,
ServerTab.event,
ServerTab.eventRules,
ServerTab.settings
]);
export function ServerConfigurationTabs({ export function ServerConfigurationTabs({
serverId, serverId,
configurations, configurations,
@@ -36,29 +49,109 @@ export function ServerConfigurationTabs({
}: ServerConfigurationTabsProps) { }: ServerConfigurationTabsProps) {
const [currentTab, setCurrentTab] = useState(ServerTab.statistics); const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const [configData, setConfigData] = useState(configurations.configuration);
const [assistData, setAssistData] = useState(configurations.assistRules);
const [eventData, setEventData] = useState(configurations.event);
const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
const [settingsData, setSettingsData] = useState(configurations.settings);
const [dirtyConfigs, setDirtyConfigs] = useState<Set<keyof Configurations>>(new Set());
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const markDirty = (key: keyof Configurations) =>
setDirtyConfigs((prev) => new Set(prev).add(key));
const handleSaveAll = async () => {
if (dirtyConfigs.size === 0) return;
setIsSubmitting(true);
setSaveError(null);
setSaveSuccess(false);
const payload: Partial<Configurations> = {};
if (dirtyConfigs.has('configuration')) payload.configuration = configData;
if (dirtyConfigs.has('assistRules')) payload.assistRules = assistData;
if (dirtyConfigs.has('event')) payload.event = eventData;
if (dirtyConfigs.has('eventRules')) payload.eventRules = eventRulesData;
if (dirtyConfigs.has('settings')) payload.settings = settingsData;
const result = await bulkUpdateConfigurationsAction(serverId, payload, restart);
if (result.success) {
setDirtyConfigs(new Set());
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} else {
setSaveError(result.message);
}
setIsSubmitting(false);
};
const isConfigTab = configTabs.has(currentTab);
const renderTabContent = () => { const renderTabContent = () => {
switch (currentTab) { switch (currentTab) {
case ServerTab.statistics: case ServerTab.statistics:
return <StatisticsDashboard stats={statistics} />; return <StatisticsDashboard stats={statistics} />;
case ServerTab.configuration: case ServerTab.configuration:
return <ConfigurationEditor serverId={serverId} config={configurations.configuration} />; return (
<ConfigurationEditor
formData={configData}
disabled={isSubmitting}
onFormDataChange={(data) => { setConfigData(data); markDirty('configuration'); }}
/>
);
case ServerTab.assistRules: case ServerTab.assistRules:
return <AssistRulesEditor serverId={serverId} config={configurations.assistRules} />; return (
<AssistRulesEditor
formData={assistData}
disabled={isSubmitting}
onFormDataChange={(data) => { setAssistData(data); markDirty('assistRules'); }}
/>
);
case ServerTab.event: case ServerTab.event:
return <EventConfigEditor serverId={serverId} config={configurations.event} />; return (
<EventConfigEditor
formData={eventData}
disabled={isSubmitting}
onFormDataChange={(data) => { setEventData(data); markDirty('event'); }}
/>
);
case ServerTab.eventRules: case ServerTab.eventRules:
return <EventRulesEditor serverId={serverId} config={configurations.eventRules} />; return (
<EventRulesEditor
formData={eventRulesData}
disabled={isSubmitting}
onFormDataChange={(data) => { setEventRulesData(data); markDirty('eventRules'); }}
/>
);
case ServerTab.settings: case ServerTab.settings:
return <ServerSettingsEditor serverId={serverId} config={configurations.settings} />; return (
<ServerSettingsEditor
formData={settingsData}
disabled={isSubmitting}
onFormDataChange={(data) => { setSettingsData(data); markDirty('settings'); }}
/>
);
case ServerTab.leaderboard: case ServerTab.leaderboard:
return <LeaderboardManager serverId={serverId} initialData={leaderboard} />; return <LeaderboardManager serverId={serverId} initialData={leaderboard} />;
case ServerTab.activityLog:
return <ActivityLogTable serverId={serverId} />;
case ServerTab.logs:
return <ServerLogViewer serverId={serverId} />;
default: default:
return ( return (
<div className="py-12 text-center"> <div className="py-12 text-center">
@@ -71,30 +164,59 @@ export function ServerConfigurationTabs({
}; };
return ( return (
<div className="overflow-hidden rounded-lg bg-gray-800"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="border-b border-gray-700"> <nav className="flex overflow-x-auto border-b border-border-muted px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" aria-label="Tabs">
<nav className="flex space-x-8 overflow-x-auto" aria-label="Tabs"> {tabs.map((tab) => {
{tabs.map((tab) => { const isActive = currentTab === tab.id;
const isActive = currentTab === tab.id; return (
return ( <button
<button key={tab.id}
key={tab.id} onClick={() => setCurrentTab(tab.id)}
onClick={() => setCurrentTab(tab.id)} className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
className={`flex items-center space-x-2 border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap transition-colors ${ isActive
isActive ? 'border-orange text-primary'
? 'border-blue-500 text-blue-400' : 'border-transparent text-muted hover:text-primary'
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300' }`}
} `} >
> <span className="text-sm">{tab.icon}</span>
<span className="text-base">{tab.icon}</span> <span>{tab.name}</span>
<span>{tab.name}</span> </button>
</button> );
); })}
})} </nav>
</nav>
</div>
<div className="p-6">{renderTabContent()}</div> {isConfigTab && (
<div className="flex items-center justify-between border-b border-border-muted bg-overlay px-5 py-3">
<div className="flex items-center gap-3">
{saveError && (
<p className="text-sm text-red">{saveError}</p>
)}
{saveSuccess && (
<p className="text-sm text-green">All configurations saved.</p>
)}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart after saving
</label>
<button
onClick={handleSaveAll}
disabled={isSubmitting || dirtyConfigs.size === 0}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save All'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div>
</div> </div>
); );
} }

10
src/components/server/ServerCreationPopup.tsx Normal file → Executable file
View File

@@ -78,15 +78,15 @@ export function ServerCreationPopup({
setEntries((prev) => [...prev, newEntry]); setEntries((prev) => [...prev, newEntry]);
}, []); }, []);
const scrollToBottom = () => { const scrollToBottom = useCallback(() => {
if (consoleRef.current && !isMinimized && isConsoleVisible) { if (consoleRef.current && !isMinimized && isConsoleVisible) {
consoleRef.current.scrollTop = consoleRef.current.scrollHeight; consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
} }
}; }, [isMinimized, isConsoleVisible]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [entries, isMinimized, isConsoleVisible]); }, [entries, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (serverId && isOpen) { if (serverId && isOpen) {
@@ -310,7 +310,7 @@ export function ServerCreationPopup({
!isCompleted && Object.values(steps).some((step) => step.status === 'in_progress'); !isCompleted && Object.values(steps).some((step) => step.status === 'in_progress');
return ( return (
<div className="fixed right-4 bottom-4 z-40"> <div className="fixed right-4 bottom-20 md:bottom-4 z-40">
<button <button
onClick={() => setIsMinimized(false)} onClick={() => setIsMinimized(false)}
className={`flex h-16 w-16 items-center justify-center rounded-full border-2 shadow-lg transition-all hover:scale-105 ${ className={`flex h-16 w-16 items-center justify-center rounded-full border-2 shadow-lg transition-all hover:scale-105 ${
@@ -364,7 +364,7 @@ export function ServerCreationPopup({
} }
return ( return (
<div className="fixed right-4 bottom-4 z-40 max-h-[600px] w-96 rounded-lg border border-gray-700 bg-gray-800 shadow-2xl select-none"> <div className="fixed right-4 bottom-20 md:bottom-4 z-40 max-h-[600px] w-96 rounded-lg border border-gray-700 bg-gray-800 shadow-2xl select-none">
<div className="flex items-center justify-between border-b border-gray-700 p-4"> <div className="flex items-center justify-between border-b border-gray-700 p-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-lg">🔧</span> <span className="text-lg">🔧</span>

0
src/components/server/ServerCreationPopupContainer.tsx Normal file → Executable file
View File

139
src/components/server/ServerHeader.tsx Normal file → Executable file
View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Server, getStatusColor, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server'; import { Server, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
@@ -10,6 +11,8 @@ import { hasPermission, User } from '@/lib/schemas';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DeleteServerModal } from './DeleteServerModal'; import { DeleteServerModal } from './DeleteServerModal';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface ServerHeaderProps { interface ServerHeaderProps {
server: Server; server: Server;
@@ -43,112 +46,42 @@ export function ServerHeader({ server, user }: ServerHeaderProps) {
ServiceStatus.Stopping, ServiceStatus.Stopping,
ServiceStatus.Unknown ServiceStatus.Unknown
].includes(server.status); ].includes(server.status);
return ( return (
<div className="rounded-lg bg-gray-800 p-6"> <>
<div className="flex items-center justify-between"> <header className="flex h-12 items-center justify-between border-b border-border-muted px-5">
<div className="flex items-center space-x-4"> <div className="flex items-center gap-2 text-sm">
<Link <Link href="/dashboard" className="text-blue hover:underline">Servers</Link>
href="/dashboard" <span className="text-subtle">/</span>
className="flex items-center text-gray-400 transition-colors hover:text-white" <span className="font-semibold text-primary">{server.name}</span>
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Dashboard
</Link>
</div> </div>
</div> <div className="flex items-center gap-2">
<Badge variant={
<div className="mt-6"> server.status === ServiceStatus.Running ? 'green' :
<div className="flex items-center justify-between"> server.status === ServiceStatus.Stopped ? 'gray' : 'yellow'
<div> }>
<h1 className="text-3xl font-bold text-white">{server.name}</h1> ● {serviceStatusToString(server.status)}
<div className="mt-2 flex items-center"> </Badge>
<span <Button variant="ghost" size="sm" onClick={startServer} disabled={server.status === ServiceStatus.Running || disabled || isPending}>Start</Button>
className={`inline-block h-3 w-3 rounded-full ${getStatusColor(server.status)} mr-3`} <Button variant="ghost" size="sm" onClick={restartServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Restart</Button>
/> <Button variant="danger-outline" size="sm" onClick={stopServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Stop</Button>
<span className="text-lg text-gray-300 capitalize"> {canDeleteServer && (
{serviceStatusToString(server.status)} <Button variant="danger-solid" size="sm" onClick={() => setIsDeleteModalOpen(true)} disabled={disabled || isPending}>Remove</Button>
</span> )}
</div>
</div>
<div className="flex space-x-3">
{canDeleteServer && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
setIsDeleteModalOpen(true);
}}
disabled={disabled || isPending}
className="mr-3 rounded bg-red-800 px-4 py-2 font-medium text-white transition-colors hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
>
Remove Server
</button>
)}
<button
type="button"
onClick={startServer}
disabled={server.status === ServiceStatus.Running || disabled || isPending}
className="rounded bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Start
</button>
<button
type="button"
onClick={restartServer}
disabled={server.status === ServiceStatus.Stopped || disabled || isPending}
className="rounded bg-yellow-600 px-4 py-2 font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Restart
</button>
<button
type="button"
onClick={stopServer}
disabled={server.status === ServiceStatus.Stopped || disabled || isPending}
className="rounded bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Stop
</button>
</div>
</div> </div>
</header>
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4"> <div className="flex flex-wrap gap-6 border-b border-border-muted px-5 py-3">
<div> {[
<div className="text-sm text-gray-500">Current Track</div> { label: 'Track', value: server.state?.track ? getTrackDisplayName(server.state.track) : 'N/A' },
<div className="text-lg font-medium text-white">{server.state?.track || 'N/A'}</div> { label: 'Players', value: `${server.state?.playerCount ?? 0} / ${server.state?.maxConnections ?? 0}` },
{ label: 'Session', value: server.state?.session ?? 'N/A' },
].map(({ label, value }) => (
<div key={label}>
<div className="text-xs text-subtle">{label}</div>
<div className="text-sm font-semibold text-primary">{value}</div>
</div> </div>
<div> ))}
<div className="text-sm text-gray-500">Players</div>
<div className="text-lg font-medium text-white">
{server.state?.playerCount || 0} / {server.state?.maxConnections || 0}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Session</div>
<div className="text-lg font-medium text-white">{server.state?.session || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">Max Connections</div>
<div className="text-lg font-medium text-white">
{server.state?.maxConnections || 0}
</div>
</div>
</div>
</div> </div>
<DeleteServerModal <DeleteServerModal
@@ -156,6 +89,6 @@ export function ServerHeader({ server, user }: ServerHeaderProps) {
onClose={() => setIsDeleteModalOpen(false)} onClose={() => setIsDeleteModalOpen(false)}
server={server} server={server}
/> />
</div> </>
); );
} }

57
src/components/server/ServerListWithActions.tsx Normal file → Executable file
View File

@@ -1,12 +1,15 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Server } from '@/lib/schemas/server'; import { Server, ServiceStatus } from '@/lib/schemas/server';
import { User, hasPermission } from '@/lib/schemas/user'; import { User, hasPermission } from '@/lib/schemas/user';
import { ServerCard } from './ServerCard'; import { ServerCard } from './ServerCard';
import { ServerRow } from './ServerRow';
import { CreateServerModal } from './CreateServerModal'; import { CreateServerModal } from './CreateServerModal';
import RefreshButton from '@/components/ui/RefreshButton'; import RefreshButton from '@/components/ui/RefreshButton';
import { useSteamCMD } from '@/lib/context/SteamCMDContext'; import { useSteamCMD } from '@/lib/context/SteamCMDContext';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
interface ServerListWithActionsProps { interface ServerListWithActionsProps {
servers: Server[]; servers: Server[];
@@ -20,29 +23,55 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr
const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []); const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []);
const canCreateServer = hasPermission(user, 'server.create'); const canCreateServer = hasPermission(user, 'server.create');
const runningCount = servers.filter((s) => s.status === ServiceStatus.Running).length;
const offlineCount = servers.filter((s) => s.status !== ServiceStatus.Running).length;
return ( return (
<> <>
<div className="mb-6 flex items-center justify-between"> <header className="border-border-muted flex h-12 items-center justify-between border-b px-5">
<h2 className="text-xl font-semibold">Your Servers</h2> <div className="flex items-center gap-3">
<div className="flex items-center space-x-4"> <span className="text-primary text-sm font-semibold">Servers</span>
<span className="text-muted text-xs">{servers.length} total</span>
</div>
<div className="flex items-center gap-2">
<RefreshButton />
{canCreateServer && ( {canCreateServer && (
<button <Button
variant="primary"
size="sm"
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
disabled={isSteamCMDRunning} disabled={isSteamCMDRunning}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" title={isSteamCMDRunning ? 'Disabled while SteamCMD is running' : undefined}
title={isSteamCMDRunning ? 'Server creation disabled while SteamCMD is running' : ''}
> >
Create Server + New Server
</button> </Button>
)} )}
<RefreshButton /> </div>
</header>
<div className="flex items-center px-5 py-3">
<div className="flex gap-2">
{runningCount > 0 && <Badge variant="green">● {runningCount} running</Badge>}
{offlineCount > 0 && <Badge variant="gray">{offlineCount} offline</Badge>}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="px-5 pb-5">
{servers.map((server) => ( {/* Card grid — desktop only */}
<ServerCard key={server.id} server={server} /> <div
))} className="hidden gap-3 md:grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))' }}
>
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
{/* Row list — mobile only */}
<div className="flex flex-col gap-2 md:hidden">
{servers.map((server) => (
<ServerRow key={server.id} server={server} />
))}
</div>
</div> </div>
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} /> <CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />

View File

@@ -0,0 +1,153 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context';
import type { WebSocketMessage } from '@/lib/schemas/websocket';
import { getServerLogAction } from '@/lib/actions/serverLog';
const MAX_BUFFER = 5000;
const LINE_OPTIONS = [100, 500, 1000, 5000] as const;
interface ServerLogViewerProps {
serverId: string;
}
export function ServerLogViewer({ serverId }: ServerLogViewerProps) {
const [lines, setLines] = useState<string[]>([]);
const [initialLines, setInitialLines] = useState<number>(100);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
const { associateWithServer, addMessageHandler, removeMessageHandler } = useWebSocket();
const scrollRef = useRef<HTMLDivElement>(null);
const isPausedRef = useRef(false);
const appendLines = useCallback((newLines: string[]) => {
setLines((prev) => {
const combined = [...prev, ...newLines];
return combined.length > MAX_BUFFER ? combined.slice(combined.length - MAX_BUFFER) : combined;
});
}, []);
const fetchInitial = useCallback(
async (count: number) => {
setIsLoading(true);
setError(null);
const result = await getServerLogAction(serverId, count);
setIsLoading(false);
if (!result.success) {
setError(result.message);
return;
}
setLines(result.lines);
},
[serverId]
);
useEffect(() => {
associateWithServer(serverId);
fetchInitial(initialLines);
}, [serverId, associateWithServer, fetchInitial, initialLines]);
const handleMessage = useCallback(
(message: WebSocketMessage) => {
if (message.type !== 'log_line') return;
if (message.server_id !== serverId) return;
appendLines([message.data.line]);
},
[serverId, appendLines]
);
useEffect(() => {
addMessageHandler(handleMessage);
return () => {
removeMessageHandler(handleMessage);
};
}, [addMessageHandler, removeMessageHandler, handleMessage]);
// Auto-scroll to bottom when new lines arrive, unless paused
useEffect(() => {
if (!isPausedRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines]);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 40;
const shouldPause = !isAtBottom;
isPausedRef.current = shouldPause;
setIsPaused(shouldPause);
};
const jumpToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
isPausedRef.current = false;
setIsPaused(false);
};
const handleLineLimitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const count = Number(e.target.value);
setInitialLines(count);
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<label className="text-xs text-subtle">Initial lines</label>
<select
value={initialLines}
onChange={handleLineLimitChange}
className="form-input w-28 text-sm"
>
{LINE_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span className="ml-auto text-xs text-subtle">{lines.length} lines buffered</span>
</div>
{error && <p className="text-sm text-red">{error}</p>}
<div className="relative">
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[calc(100vh-280px)] min-h-64 overflow-y-auto rounded-lg border border-border bg-base p-3 font-mono text-xs text-secondary"
>
{isLoading ? (
<span className="text-muted">Loading…</span>
) : lines.length === 0 ? (
<span className="text-muted">No log output yet.</span>
) : (
lines.map((line, i) => (
<div key={i} className="leading-5 whitespace-pre-wrap break-all">
{line}
</div>
))
)}
</div>
{isPaused && (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2">
<div className="flex items-center gap-2 rounded-full border border-border bg-overlay px-3 py-1.5 shadow-lg">
<span className="text-xs text-muted">Paused</span>
<button
onClick={jumpToBottom}
className="text-xs font-medium text-blue hover:underline"
>
Jump to bottom
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Server, ServiceStatus } from '@/lib/schemas';
import { getTrackDisplayName } from '@/lib/constants/tracks';
import {
startServerEventAction,
restartServerEventAction,
stopServerEventAction
} from '@/lib/actions/servers';
import { StatusDot } from '@/components/ui/StatusDot';
import { Button } from '@/components/ui/Button';
interface ServerRowProps {
server: Server;
}
export function ServerRow({ server }: ServerRowProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const startServer = () =>
startTransition(async () => {
await startServerEventAction(server.id);
router.refresh();
});
const restartServer = () =>
startTransition(async () => {
await restartServerEventAction(server.id);
router.refresh();
});
const stopServer = () =>
startTransition(async () => {
await stopServerEventAction(server.id);
router.refresh();
});
const transitioning = [
ServiceStatus.Restarting,
ServiceStatus.Starting,
ServiceStatus.Stopping,
ServiceStatus.Unknown,
].includes(server.status);
const disabled = transitioning || isPending;
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-canvas px-3 py-2.5 transition-colors hover:border-border/60">
<Link href={`/dashboard/server/${server.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<StatusDot status={server.status} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-primary">{server.name}</div>
<div className="truncate text-xs text-muted">
{server.state?.track ? getTrackDisplayName(server.state.track) : 'No track'} · {server.state?.playerCount ?? 0} players
</div>
</div>
</Link>
<div className="flex shrink-0 gap-1.5">
{server.status === ServiceStatus.Stopped ? (
<Button variant="primary" size="sm" onClick={startServer} disabled={disabled}>
Start
</Button>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={restartServer}
disabled={disabled}
>
Restart
</Button>
<Button
variant="danger-outline"
size="sm"
onClick={stopServer}
disabled={disabled}
>
Stop
</Button>
</>
)}
</div>
</div>
);
}

14
src/components/statistics/DailyActivityChart.tsx Normal file → Executable file
View File

@@ -29,8 +29,8 @@ export function DailyActivityChart({ data }: DailyActivityChartProps) {
{ {
label: 'Sessions', label: 'Sessions',
data: data.map((item) => item.sessionsCount), data: data.map((item) => item.sessionsCount),
backgroundColor: 'rgba(59, 130, 246, 0.8)', backgroundColor: '#3fb950',
borderColor: 'rgb(59, 130, 246)', borderColor: '#3fb950',
borderWidth: 1 borderWidth: 1
} }
] ]
@@ -43,27 +43,27 @@ export function DailyActivityChart({ data }: DailyActivityChartProps) {
legend: { legend: {
position: 'top' as const, position: 'top' as const,
labels: { labels: {
color: '#e5e7eb' color: '#e6edf3'
} }
} }
}, },
scales: { scales: {
x: { x: {
ticks: { ticks: {
color: '#9ca3af' color: '#8b949e'
}, },
grid: { grid: {
color: '#374151' color: '#21262d'
} }
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
ticks: { ticks: {
color: '#9ca3af', color: '#8b949e',
stepSize: 1 stepSize: 1
}, },
grid: { grid: {
color: '#374151' color: '#21262d'
} }
} }
} }

14
src/components/statistics/PlayerCountChart.tsx Normal file → Executable file
View File

@@ -43,8 +43,8 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
x: new Date(point.timestamp), x: new Date(point.timestamp),
y: point.count y: point.count
})), })),
borderColor: 'rgb(59, 130, 246)', borderColor: '#58a6ff',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: '#58a6ff1a',
tension: 0.4 tension: 0.4
} }
] ]
@@ -57,7 +57,7 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
legend: { legend: {
position: 'top' as const, position: 'top' as const,
labels: { labels: {
color: '#e5e7eb' color: '#e6edf3'
} }
} }
}, },
@@ -68,20 +68,20 @@ export function PlayerCountChart({ data }: PlayerCountChartProps) {
unit: 'hour' as const unit: 'hour' as const
}, },
ticks: { ticks: {
color: '#9ca3af' color: '#8b949e'
}, },
grid: { grid: {
color: '#374151' color: '#21262d'
} }
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
ticks: { ticks: {
color: '#9ca3af', color: '#8b949e',
stepSize: 1 stepSize: 1
}, },
grid: { grid: {
color: '#374151' color: '#21262d'
} }
} }
} }

43
src/components/statistics/RecentSessions.tsx Normal file → Executable file
View File

@@ -1,3 +1,5 @@
import { getTrackDisplayName } from '@/lib/constants/tracks';
interface RecentSession { interface RecentSession {
id: string; id: string;
date: string; date: string;
@@ -29,51 +31,52 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="py-8 text-center"> <div className="px-4 py-8 text-center text-sm text-muted">No recent sessions found</div>
<div className="mb-2 text-4xl">📊</div>
<p className="text-gray-400">No recent sessions found</p>
</div>
); );
} }
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-700"> <table className="min-w-full">
<thead> <thead>
<tr> <tr className="border-b border-border-muted bg-base">
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Date Date
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Type Type
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Track Track
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Duration Duration
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Players Players
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700"> <tbody>
{sessions.map((session) => ( {sessions.map((session) => (
<tr key={session.id} className="transition-colors hover:bg-gray-700"> <tr key={session.id} className="border-b border-border-muted transition-colors hover:bg-hover last:border-0">
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300"> <td className="px-4 py-3 text-sm text-muted">
{formatDate(session.date)} {formatDate(session.date)}
</td> </td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300"> <td className="px-4 py-3">
<span className="inline-flex items-center rounded-full bg-blue-900 px-2.5 py-0.5 text-xs font-medium text-blue-300"> <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
{session.type} session.type === 'R' ? 'bg-green-bg text-green' :
session.type === 'Q' ? 'bg-yellow-bg text-yellow' :
'bg-blue-bg text-blue'
}`}>
{session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">{session.track}</td> <td className="px-4 py-3 text-sm text-secondary">{getTrackDisplayName(session.track)}</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300"> <td className="px-4 py-3 text-sm text-muted">
{formatDuration(session.duration)} {formatDuration(session.duration)}
</td> </td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300"> <td className="px-4 py-3 text-sm text-secondary">
{session.players} {session.players}
</td> </td>
</tr> </tr>

12
src/components/statistics/SessionTypesChart.tsx Normal file → Executable file
View File

@@ -15,12 +15,10 @@ interface SessionTypesChartProps {
} }
const colors = [ const colors = [
'#3b82f6', // blue '#3fb950', // green
'#10b981', // emerald '#58a6ff', // blue
'#f59e0b', // amber '#f78166', // red
'#ef4444', // red '#d29922' // yellow
'#8b5cf6', // violet
'#06b6d4' // cyan
]; ];
export function SessionTypesChart({ data }: SessionTypesChartProps) { export function SessionTypesChart({ data }: SessionTypesChartProps) {
@@ -43,7 +41,7 @@ export function SessionTypesChart({ data }: SessionTypesChartProps) {
legend: { legend: {
position: 'bottom' as const, position: 'bottom' as const,
labels: { labels: {
color: '#e5e7eb', color: '#e6edf3',
padding: 20 padding: 20
} }
}, },

14
src/components/statistics/StatCard.tsx Normal file → Executable file
View File

@@ -6,16 +6,12 @@ interface StatCardProps {
export function StatCard({ title, value, icon }: StatCardProps) { export function StatCard({ title, value, icon }: StatCardProps) {
return ( return (
<div className="rounded-lg bg-gray-800 p-6"> <div className="rounded-lg border border-border bg-canvas p-4">
<div className="flex items-center"> <div className="mb-1 flex items-center gap-2">
<div className="flex-shrink-0"> <span className="text-base">{icon}</span>
<span className="text-3xl">{icon}</span> <span className="text-xs text-muted">{title}</span>
</div>
<div className="ml-4">
<div className="text-2xl font-semibold text-white">{value}</div>
<div className="text-sm text-gray-400">{title}</div>
</div>
</div> </div>
<div className="text-2xl font-bold text-primary">{value}</div>
</div> </div>
); );
} }

35
src/components/statistics/StatisticsDashboard.tsx Normal file → Executable file
View File

@@ -14,21 +14,21 @@ interface StatisticsDashboardProps {
export function StatisticsDashboard({ stats }: StatisticsDashboardProps) { export function StatisticsDashboard({ stats }: StatisticsDashboardProps) {
if (!stats) { if (!stats) {
return ( return (
<div className="py-12 text-center"> <div className="py-16 text-center">
<div className="mb-4 text-6xl">📊</div> <div className="mb-3 text-5xl">📊</div>
<h3 className="mb-2 text-xl font-semibold text-white">No Statistics Available</h3> <h3 className="mb-1 text-base font-semibold text-primary">No Statistics Available</h3>
<p className="text-gray-400">No data found for the selected date range.</p> <p className="text-sm text-muted">No data found for the selected date range.</p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard title="Total Sessions" value={stats.totalSessions ?? 0} icon="🏁" /> <StatCard title="Total Sessions" value={stats.totalSessions ?? 0} icon="🏁" />
<StatCard <StatCard
title="Total Playtime" title="Total Playtime"
value={`${Math.round(stats.totalPlaytime ?? 0 / 60)}h`} value={`${Math.round((stats.totalPlaytime ?? 0) / 60)}h`}
icon="⏱️" icon="⏱️"
/> />
<StatCard <StatCard
@@ -39,27 +39,24 @@ export function StatisticsDashboard({ stats }: StatisticsDashboardProps) {
<StatCard title="Peak Players" value={stats.peakPlayers ?? 0} icon="🔥" /> <StatCard title="Peak Players" value={stats.peakPlayers ?? 0} icon="🔥" />
</div> </div>
{/* Charts */} <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="grid grid-cols-12 gap-4"> <div className="lg:col-span-2 rounded-lg border border-border bg-canvas p-4">
<div className="col-span-9 rounded-lg bg-gray-800 p-6"> <h3 className="mb-3 text-sm font-semibold text-primary">Player Count Over Time</h3>
<h3 className="mb-4 text-lg font-medium text-white">Player Count Over Time</h3>
<PlayerCountChart data={stats.playerCountOverTime ?? []} /> <PlayerCountChart data={stats.playerCountOverTime ?? []} />
</div> </div>
<div className="rounded-lg border border-border bg-canvas p-4">
<div className="col-span-3 rounded-lg bg-gray-800 p-6"> <h3 className="mb-3 text-sm font-semibold text-primary">Session Types</h3>
<h3 className="mb-4 text-lg font-medium text-white">Session Types</h3>
<SessionTypesChart data={stats.sessionTypes ?? []} /> <SessionTypesChart data={stats.sessionTypes ?? []} />
</div> </div>
</div> </div>
<div className="rounded-lg bg-gray-800 p-6"> <div className="rounded-lg border border-border bg-canvas p-4">
<h3 className="mb-4 text-lg font-medium text-white">Daily Activity</h3> <h3 className="mb-3 text-sm font-semibold text-primary">Daily Activity</h3>
<DailyActivityChart data={stats.dailyActivity ?? []} /> <DailyActivityChart data={stats.dailyActivity ?? []} />
</div> </div>
{/* Recent Sessions */} <div className="overflow-hidden rounded-lg border border-border bg-canvas">
<div className="rounded-lg bg-gray-800 p-6"> <div className="border-b border-border-muted px-4 py-3 text-sm font-semibold text-primary">Recent Sessions</div>
<h3 className="mb-4 text-lg font-medium text-white">Recent Sessions</h3>
<RecentSessions sessions={stats.recentSessions ?? []} /> <RecentSessions sessions={stats.recentSessions ?? []} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,19 @@
// NOTE: intentionally no 'use client' — must remain a Server Component
import { Sidebar } from './Sidebar';
interface AppShellProps {
children: React.ReactNode;
showUsers: boolean;
}
export function AppShell({ children, showUsers }: AppShellProps) {
return (
<div className="min-h-screen bg-base">
<Sidebar showUsers={showUsers} />
{/* Offset for sidebar (desktop: left margin, mobile: bottom padding) */}
<div className="pb-14 md:pb-0 md:pl-14">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils';
type BadgeVariant = 'green' | 'yellow' | 'red' | 'blue' | 'gray';
interface BadgeProps {
variant: BadgeVariant;
children: React.ReactNode;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
green: 'bg-green-bg text-green border border-green/20',
yellow: 'bg-yellow-bg text-yellow border border-yellow/20',
red: 'bg-red-bg text-red border border-red/20',
blue: 'bg-blue-bg text-blue border border-blue/20',
gray: 'bg-overlay text-muted border border-border',
};
export function Badge({ variant, children, className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
variantClasses[variant],
className
)}
>
{children}
</span>
);
}

View File

@@ -1,46 +1,49 @@
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { LoadingSpinner } from './LoadingSpinner'; import { ButtonHTMLAttributes } from 'react';
type ButtonVariant = 'primary' | 'ghost' | 'danger-outline' | 'danger-solid';
type ButtonSize = 'sm' | 'md' | 'logout';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; variant?: ButtonVariant;
size?: 'sm' | 'md' | 'lg'; size?: ButtonSize;
isLoading?: boolean;
} }
const baseClasses = const variantClasses: Record<ButtonVariant, string> = {
'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; primary: 'bg-btn-green border border-btn-green-hover text-white hover:bg-btn-green-hover',
ghost: 'bg-transparent border border-border text-secondary hover:bg-overlay hover:text-primary',
const variants = { 'danger-outline':
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', 'bg-transparent border border-border text-red hover:border-red/40 hover:bg-red-bg',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500', 'danger-solid': 'bg-overlay border border-red/20 text-red hover:bg-red-bg'
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500'
}; };
const sizes = { const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1 text-sm', sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm', md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base' logout: 'px-2 py-2 cursor-pointer hover:bg-red'
}; };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export function Button({
( variant = 'ghost',
{ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, size = 'md',
ref className,
) => { children,
return ( disabled,
<button ...props
className={cn(baseClasses, variants[variant], sizes[size], className)} }: ButtonProps) {
disabled={disabled || isLoading} return (
ref={ref} <button
{...props} {...props}
> disabled={disabled}
{isLoading && <LoadingSpinner size="sm" className="mr-2" />} className={cn(
{children} 'focus:ring-blue focus:ring-offset-base inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus:ring-2 focus:ring-offset-1 focus:outline-none',
</button> 'disabled:cursor-not-allowed disabled:opacity-40',
); variantClasses[variant],
} sizeClasses[size],
); className
)}
Button.displayName = 'Button'; >
{children}
</button>
);
}

0
src/components/ui/Input.tsx Normal file → Executable file
View File

0
src/components/ui/LoadingSpinner.tsx Normal file → Executable file
View File

8
src/components/ui/LogoutButton.tsx Normal file → Executable file
View File

@@ -2,15 +2,16 @@
import { logoutAction } from '@/lib/actions/auth'; import { logoutAction } from '@/lib/actions/auth';
import { useActionState } from 'react'; import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
export default function LogoutButton() { export default function LogoutButton() {
const [, formAction] = useActionState(logoutAction, null); const [, formAction] = useActionState(logoutAction, null);
return ( return (
<form action={formAction}> <form action={formAction}>
<button type="submit" className="flex items-center text-gray-300 hover:text-white"> <Button type="submit" title="Logout" variant="ghost" size="logout">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6" className="h-5 w-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -22,8 +23,7 @@ export default function LogoutButton() {
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/> />
</svg> </svg>
<span className="ml-1 hidden sm:inline">Logout</span> </Button>
</button>
</form> </form>
); );
} }

13
src/components/ui/Modal.tsx Normal file → Executable file
View File

@@ -33,17 +33,20 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-opacity-50 absolute inset-0 bg-black" onClick={onClose} /> <div className="absolute inset-0" onClick={onClose} aria-label="Close" />
<div <div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={cn( className={cn(
'relative mx-4 w-full max-w-md rounded-lg bg-gray-800 p-6 shadow-lg', 'relative w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl',
className className
)} )}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{title}</h3> <h3 id="modal-title" className="text-lg font-semibold text-primary">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white"> <button onClick={onClose} className="text-muted hover:text-primary" aria-label="Close modal">
× ×
</button> </button>
</div> </div>

0
src/components/ui/Pagination.tsx Normal file → Executable file
View File

8
src/components/ui/RefreshButton.tsx Normal file → Executable file
View File

@@ -1,15 +1,13 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
export default function RefreshButton() { export default function RefreshButton() {
const router = useRouter(); const router = useRouter();
return ( return (
<button <Button variant="ghost" size="sm" onClick={() => router.refresh()}>
onClick={() => router.refresh()}
className="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
>
Refresh Refresh
</button> </Button>
); );
} }

0
src/components/ui/Select.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,143 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import LogoutButton from './LogoutButton';
interface SidebarProps {
showUsers: boolean; // true if user has membership.view permission
}
function ServerIcon({ active }: { active: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
>
<rect x="2" y="3" width="20" height="5" rx="1" />
<rect x="2" y="10" width="20" height="5" rx="1" />
<rect x="2" y="17" width="20" height="5" rx="1" />
<circle cx="6" cy="5.5" r="1" fill="currentColor" />
<circle cx="6" cy="12.5" r="1" fill="currentColor" />
<circle cx="6" cy="19.5" r="1" fill="currentColor" />
</svg>
);
}
function UsersIcon({ active }: { active: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
);
}
function ActivityLogIcon({ active }: { active: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
>
<path d="M9 12h6M9 16h6M9 8h6M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
</svg>
);
}
export function Sidebar({ showUsers }: SidebarProps) {
const pathname = usePathname();
const isServers = pathname === '/dashboard' || pathname.startsWith('/dashboard/server');
const isUsers = pathname.startsWith('/dashboard/membership');
const isActivityLog = pathname.startsWith('/dashboard/activity-log');
const navItems = [
{
href: '/dashboard',
label: 'Servers',
active: isServers,
icon: <ServerIcon active={isServers} />
},
{
href: '/dashboard/activity-log',
label: 'Activity Log',
active: isActivityLog,
icon: <ActivityLogIcon active={isActivityLog} />
},
...(showUsers
? [
{
href: '/dashboard/membership',
label: 'Users',
active: isUsers,
icon: <UsersIcon active={isUsers} />
}
]
: [])
];
return (
<>
<aside className="border-border-muted bg-sidebar fixed inset-y-0 left-0 z-40 hidden w-14 flex-col items-center border-r py-3 md:flex">
<div className="bg-blue mb-4 flex h-8 w-8 items-center justify-center rounded-lg">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#0d1117"
strokeWidth={2.5}
className="h-4 w-4"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<nav className="flex flex-1 flex-col items-center gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
title={item.label}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
item.active ? 'bg-blue-bg' : 'hover:bg-overlay'
)}
>
{item.icon}
</Link>
))}
</nav>
<LogoutButton />
</aside>
<nav className="border-border-muted bg-sidebar fixed inset-x-0 bottom-0 z-40 flex h-14 items-center justify-around border-t md:hidden">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center gap-0.5 px-4 py-2 text-xs transition-colors',
item.active ? 'text-blue' : 'text-subtle'
)}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
</nav>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { ServiceStatus } from '@/lib/schemas/server';
import { cn } from '@/lib/utils';
interface StatusDotProps {
status: ServiceStatus;
className?: string;
}
export function StatusDot({ status, className }: StatusDotProps) {
const dotClass = {
[ServiceStatus.Running]: 'bg-green shadow-[0_0_0_3px_#3fb95022]',
[ServiceStatus.Starting]: 'bg-yellow animate-pulse',
[ServiceStatus.Stopping]: 'bg-yellow animate-pulse',
[ServiceStatus.Restarting]: 'bg-yellow animate-pulse',
[ServiceStatus.Stopped]: 'bg-subtle',
[ServiceStatus.Unknown]: 'bg-subtle',
}[status] ?? 'bg-subtle';
return (
<span
className={cn(
'inline-block h-2 w-2 rounded-full flex-shrink-0',
dotClass,
className
)}
/>
);
}

10
src/components/ui/SteamCMDNotification.tsx Normal file → Executable file
View File

@@ -12,11 +12,11 @@ export function SteamCMDNotification() {
const serverCount = runningSteamServers.size; const serverCount = runningSteamServers.size;
return ( return (
<div className="bg-yellow-600 border-l-4 border-yellow-400 p-4"> <div className="border-l-4 border-yellow bg-yellow-bg px-4 py-3">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg <svg
className="h-5 w-5 text-yellow-50" className="h-5 w-5 text-yellow"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
> >
@@ -28,7 +28,7 @@ export function SteamCMDNotification() {
</svg> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-yellow-50"> <p className="text-sm text-yellow">
<strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}. <strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}.
Server actions are temporarily disabled to prevent conflicts. Server actions are temporarily disabled to prevent conflicts.
This will automatically resolve when the download completes. This will automatically resolve when the download completes.
@@ -36,8 +36,8 @@ export function SteamCMDNotification() {
</div> </div>
<div className="ml-auto flex-shrink-0"> <div className="ml-auto flex-shrink-0">
<div className="flex items-center"> <div className="flex items-center">
<div className="animate-spin h-4 w-4 border-2 border-yellow-50 border-t-transparent rounded-full mr-2"></div> <div className="animate-spin h-4 w-4 border-2 border-yellow border-t-transparent rounded-full mr-2"></div>
<span className="text-xs text-yellow-50">Downloading...</span> <span className="text-xs text-yellow">Downloading...</span>
</div> </div>
</div> </div>
</div> </div>

0
src/components/ui/Toast.tsx Normal file → Executable file
View File

0
src/components/websocket/WebSocketInitializer.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,20 @@
'use server';
import { requireAuth } from '@/lib/auth/server';
import { getActivityLog } from '@/lib/api/server/activityLog';
import type { ActivityLog, ActivityLogFilter } from '@/lib/schemas/activityLog';
export async function getActivityLogAction(
filter?: ActivityLogFilter
): Promise<{ success: true; data: ActivityLog[] } | { success: false; message: string }> {
try {
const session = await requireAuth();
const data = await getActivityLog(session.token!, filter);
return { success: true, data };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to fetch activity log'
};
}
}

0
src/lib/actions/auth.ts Normal file → Executable file
View File

31
src/lib/actions/configuration.ts Normal file → Executable file
View File

@@ -2,10 +2,11 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration } from '@/lib/api/server/configuration'; import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import { import {
assistRulesSchema, assistRulesSchema,
ConfigFile, ConfigFile,
configurationsSchema,
eventConfigSchema, eventConfigSchema,
eventRulesSchema, eventRulesSchema,
serverSettingsSchema serverSettingsSchema
@@ -13,6 +14,7 @@ import {
import type { import type {
Configuration, Configuration,
AssistRules, AssistRules,
Configurations,
EventConfig, EventConfig,
EventRules, EventRules,
ServerSettings ServerSettings
@@ -112,6 +114,7 @@ export async function updateServerSettingsAction(serverId: string, formData: For
centralEntryListPath: formData.get('centralEntryListPath') as string, centralEntryListPath: formData.get('centralEntryListPath') as string,
allowAutoDQ: parseInt(formData.get('allowAutoDQ') as string), allowAutoDQ: parseInt(formData.get('allowAutoDQ') as string),
shortFormationLap: parseInt(formData.get('shortFormationLap') as string), shortFormationLap: parseInt(formData.get('shortFormationLap') as string),
dumpEntryList: parseInt(formData.get('dumpEntryList') as string),
formationLapType: parseInt(formData.get('formationLapType') as string), formationLapType: parseInt(formData.get('formationLapType') as string),
ignorePrematureDisconnects: parseInt(formData.get('ignorePrematureDisconnects') as string) ignorePrematureDisconnects: parseInt(formData.get('ignorePrematureDisconnects') as string)
}; };
@@ -199,6 +202,7 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
driverStintTimeSec: parseInt(formData.get('driverStintTimeSec') as string), driverStintTimeSec: parseInt(formData.get('driverStintTimeSec') as string),
mandatoryPitstopCount: parseInt(formData.get('mandatoryPitstopCount') as string), mandatoryPitstopCount: parseInt(formData.get('mandatoryPitstopCount') as string),
maxTotalDrivingTime: parseInt(formData.get('maxTotalDrivingTime') as string), maxTotalDrivingTime: parseInt(formData.get('maxTotalDrivingTime') as string),
maxDriversCount: parseInt(formData.get('maxDriversCount') as string),
isRefuellingAllowedInRace: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'), isRefuellingAllowedInRace: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'),
isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'), isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'),
isMandatoryPitstopRefuellingRequired: isMandatoryPitstopRefuellingRequired:
@@ -232,3 +236,28 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
}; };
} }
} }
export async function bulkUpdateConfigurationsAction(
serverId: string,
configs: Partial<Configurations>,
restart: boolean
) {
try {
const session = await requireAuth();
const validated = configurationsSchema.partial().safeParse(configs);
if (!validated.success) {
return { success: false, message: validated.error.message };
}
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'All configurations updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update configurations'
};
}
}

0
src/lib/actions/leaderboard.ts Normal file → Executable file
View File

0
src/lib/actions/membership.ts Normal file → Executable file
View File

0
src/lib/actions/server-management.ts Normal file → Executable file
View File

View File

@@ -0,0 +1,20 @@
'use server';
import { requireAuth } from '@/lib/auth/server';
import { getServerLog } from '@/lib/api/server/serverLog';
export async function getServerLogAction(
serverId: string,
lines: number = 100
): Promise<{ success: true; lines: string[] } | { success: false; message: string }> {
try {
const session = await requireAuth();
const data = await getServerLog(session.token!, serverId, lines);
return { success: true, lines: data };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to fetch server log'
};
}
}

0
src/lib/actions/servers.ts Normal file → Executable file
View File

0
src/lib/api/client/base.ts Normal file → Executable file
View File

0
src/lib/api/client/hooks.ts Normal file → Executable file
View File

0
src/lib/api/client/query-client.ts Normal file → Executable file
View File

View File

@@ -0,0 +1,31 @@
import { fetchServerAPI } from './base';
import {
activityLogSchema,
type ActivityLog,
type ActivityLogFilter
} from '@/lib/schemas/activityLog';
function buildQuery(filter?: ActivityLogFilter): string {
if (!filter) return '';
const params = new URLSearchParams();
if (filter.page !== undefined) params.set('page', String(filter.page));
if (filter.page_size !== undefined) params.set('page_size', String(filter.page_size));
if (filter.sort_by) params.set('sort_by', filter.sort_by);
if (filter.sort_desc !== undefined) params.set('sort_desc', String(filter.sort_desc));
if (filter.user_id) params.set('user_id', filter.user_id);
if (filter.server_id) params.set('server_id', filter.server_id);
if (filter.action) params.set('action', filter.action);
if (filter.start_date) params.set('start_date', filter.start_date);
if (filter.end_date) params.set('end_date', filter.end_date);
const str = params.toString();
return str ? `?${str}` : '';
}
export async function getActivityLog(
token: string,
filter?: ActivityLogFilter
): Promise<ActivityLog[]> {
const query = buildQuery(filter);
const response = await fetchServerAPI<ActivityLog[]>(`/activity-log${query}`, token);
return activityLogSchema.array().parse(response.data);
}

0
src/lib/api/server/auth.ts Normal file → Executable file
View File

4
src/lib/api/server/base.ts Normal file → Executable file
View File

@@ -30,9 +30,7 @@ export async function fetchServerAPI<T>(
if (response.status == 401) { if (response.status == 401) {
redirect('/login?expired=true'); redirect('/login?expired=true');
} }
throw new Error( throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`);
`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint} - ${token}`
);
} }
if (response.headers.get('Content-Type')?.includes('application/json')) { if (response.headers.get('Content-Type')?.includes('application/json')) {

39
src/lib/api/server/configuration.ts Normal file → Executable file
View File

@@ -8,14 +8,23 @@ import {
} from '@/lib/schemas/config'; } from '@/lib/schemas/config';
import * as z from 'zod'; import * as z from 'zod';
export type ConfigAuditRecord = {
id: string;
serverId: string;
configFile: string;
oldConfig: string;
newConfig: string;
changedAt: string;
};
const serverRoute = '/server'; const serverRoute = '/server';
export async function getServerConfigurations( export async function getServerConfigurations(
token: string, token: string,
serverId: string serverId: string
): Promise<Configurations> { ): Promise<Configurations> {
const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token); const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
return configurationsSchema.parse(response.data); return configurationsSchema.parse(response.data);
} }
export function validateConfig( export function validateConfig(
@@ -45,8 +54,26 @@ export async function updateServerConfiguration(
config: Config, config: Config,
restart = false restart = false
): Promise<void> { ): Promise<void> {
await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}?override=true`, token, 'PUT', { await fetchServerAPI(
...validateConfig(configType, config), `${serverRoute}/${serverId}/config/${configType}?override=true&restart=${restart}`,
restart token,
}); 'PUT',
{
...validateConfig(configType, config)
}
);
}
export async function bulkUpdateServerConfigurations(
token: string,
serverId: string,
configs: Partial<Configurations>,
restart = false
): Promise<void> {
await fetchServerAPI<ConfigAuditRecord[]>(
`${serverRoute}/${serverId}/config?override=true&restart=${restart}`,
token,
'PATCH',
configs
);
} }

View File

@@ -0,0 +1,17 @@
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export type HealthStatus = { healthy: true; version: string } | { healthy: false; version: null };
export async function checkHealth(): Promise<HealthStatus> {
try {
const response = await fetch(`${BASE_URL}/system/health`, {
method: 'GET',
next: { revalidate: 0 }
});
if (!response.ok) return { healthy: false, version: null };
const version = await response.text();
return { healthy: true, version: version.trim() || 'unknown' };
} catch {
return { healthy: false, version: null };
}
}

0
src/lib/api/server/leaderboard.ts Normal file → Executable file
View File

0
src/lib/api/server/lookups.ts Normal file → Executable file
View File

1
src/lib/api/server/membership.ts Normal file → Executable file
View File

@@ -1,4 +1,3 @@
import { use } from 'react';
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import { User, Role, userSchema, UserCreate, userCreateSchema, roleSchema } from '@/lib/schemas'; import { User, Role, userSchema, UserCreate, userCreateSchema, roleSchema } from '@/lib/schemas';

View File

@@ -0,0 +1,13 @@
import { fetchServerAPI } from './base';
export async function getServerLog(
token: string,
serverId: string,
lines: number = 100
): Promise<string[]> {
const response = await fetchServerAPI<{ lines: string[] }>(
`/server/${serverId}/log?lines=${lines}`,
token
);
return response.data?.lines ?? [];
}

0
src/lib/api/server/servers.ts Normal file → Executable file
View File

0
src/lib/api/server/statistics.ts Normal file → Executable file
View File

0
src/lib/auth/server.ts Normal file → Executable file
View File

View File

@@ -0,0 +1,31 @@
export const TRACKS: Record<string, string> = {
barcelona: 'Barcelona',
brands_hatch: 'Brands Hatch',
cota: 'COTA',
donington: 'Donington',
hungaroring: 'Hungaroring',
imola: 'Imola',
indianapolis: 'Indianapolis',
kyalami: 'Kyalami',
laguna_seca: 'Laguna Seca',
misano: 'Misano',
monza: 'Monza',
mount_panorama: 'Mount Panorama',
nurburgring: 'Nürburgring',
nurburgring_24h: 'Nürburgring 24h',
oulton_park: 'Oulton Park',
paul_ricard: 'Paul Ricard',
red_bull_ring: 'Red Bull Ring',
silverstone: 'Silverstone',
snetterton: 'Snetterton',
spa: 'Spa-Francorchamps',
suzuka: 'Suzuka',
valencia: 'Valencia',
watkins_glen: 'Watkins Glen',
zandvoort: 'Zandvoort',
zolder: 'Zolder'
};
export function getTrackDisplayName(key: string): string {
return TRACKS[key] ?? key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

0
src/lib/context/ServerCreationPopupContext.tsx Normal file → Executable file
View File

0
src/lib/context/SteamCMDContext.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,62 @@
import * as z from 'zod';
export const actionTypeSchema = z.enum([
'config_update',
'server_start',
'server_stop',
'server_restart',
'leaderboard_update'
]);
export type ActionType = z.infer<typeof actionTypeSchema>;
// Minimal server shape embedded in activity log responses (avoids circular import with server.ts)
const embeddedServerSchema = z.object({
id: z.string(),
name: z.string()
}).passthrough();
export type EmbeddedServer = z.infer<typeof embeddedServerSchema>;
export const activityLogSchema = z.object({
id: z.string(),
serverId: z.string(),
server: embeddedServerSchema.optional(),
userId: z.string(),
username: z.string(),
action: actionTypeSchema,
details: z.string(),
createdAt: z.string()
});
export type ActivityLog = z.infer<typeof activityLogSchema>;
export const lastActivityInfoSchema = z.object({
updatedAt: z.string(),
username: z.string(),
action: actionTypeSchema
});
export type LastActivityInfo = z.infer<typeof lastActivityInfoSchema>;
export const activityLogFilterSchema = z.object({
page: z.number().optional(),
page_size: z.number().optional(),
sort_by: z.string().optional(),
sort_desc: z.boolean().optional(),
user_id: z.string().optional(),
server_id: z.string().optional(),
action: actionTypeSchema.optional(),
start_date: z.string().optional(),
end_date: z.string().optional()
});
export type ActivityLogFilter = z.infer<typeof activityLogFilterSchema>;
export const ACTION_TYPE_LABELS: Record<ActionType, string> = {
config_update: 'Config Update',
server_start: 'Server Start',
server_stop: 'Server Stop',
server_restart: 'Server Restart',
leaderboard_update: 'Leaderboard Update'
};

Some files were not shown because too many files have changed in this diff Show More