57 Commits

Author SHA1 Message Date
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
e2f86fbcb6 update leaderboard schema to match new API wire format with flattened FL points 2026-03-01 16:41:16 +01:00
adf123f56a migrate leaderboard to driver ID-based wire format 2026-03-01 16:22:35 +01:00
3ba68ac973 add 0 fallaback 2026-03-01 15:04:09 +01:00
67a72f9a92 fix leaderboard 2026-03-01 14:33:21 +01:00
51c45ad4a9 fix build errors 2026-03-01 12:23:38 +01:00
61f0b69176 Merge branch 'feature/leaderboard' 2026-03-01 12:17:13 +01:00
2976468c9f update results with DNS as default 2026-02-28 18:02:43 +01:00
d56a399e8e add cota and resolve fetch issues 2026-02-22 17:50:09 +01:00
8dc8f98add add leaderboard management 2026-02-22 12:41:28 +01:00
d8d008b5e8 add missing tracks 2025-12-20 15:16:24 +01:00
51f45a806e add nullable to state 2025-11-15 22:40:09 +01:00
5b8698cf81 add bool to int mapping 2025-10-14 00:30:57 +02:00
69a3836f13 fix wrong type 2025-10-14 00:23:43 +02:00
69d92a3fc9 fix config rules and add override to config files 2025-10-14 00:06:26 +02:00
e8bf8498b8 update config rules 2025-09-27 22:14:20 +02:00
Fran Jurmanović
f15a0175b8 install zod 2025-09-24 20:16:14 +02:00
Fran Jurmanović
996f1a1385 introduce zod schemas 2025-09-24 20:07:57 +02:00
Fran Jurmanović
0cbc6935db use login?expired=true for unauthorized logout 2025-09-22 23:29:30 +02:00
Fran Jurmanović
6563396a83 fix logout issues 2025-09-22 22:49:15 +02:00
Fran Jurmanović
c005090ab1 resolve logout issues 2025-09-22 20:45:02 +02:00
Fran Jurmanović
6aeb654abf lazy load socket connection 2025-09-21 22:53:23 +02:00
119 changed files with 10326 additions and 8706 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

13072
package-lock.json generated Normal file → Executable file

File diff suppressed because it is too large Load Diff

5
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.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -29,7 +29,8 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12" "tailwindcss": "^4.1.12",
"zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

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>
);
}

32
src/app/dashboard/layout.tsx Executable file
View File

@@ -0,0 +1,32 @@
import { WebSocketProvider } from '@/lib/websocket/context';
import { SteamCMDProvider } from '@/lib/context/SteamCMDContext';
import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer';
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({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireAuth();
const health = await checkHealth();
if (!health.healthy) {
redirect('/login');
}
const showUsers = hasPermission(session.user!, 'membership.view');
return (
<WebSocketProvider openToken={session.openToken!}>
<SteamCMDProvider>
<ServerCreationPopupProvider>
<AppShell showUsers={showUsers}>
{children}
<ServerCreationPopupContainer />
</AppShell>
</ServerCreationPopupProvider>
</SteamCMDProvider>
</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>
); );
} }

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

@@ -1,12 +1,6 @@
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/types';
import { ServerCard } from '@/components/server/ServerCard';
import { logoutAction } from '@/lib/actions/auth';
import RefreshButton from '@/components/ui/RefreshButton';
import Link from 'next/link';
import { ServerListWithActions } from '@/components/server/ServerListWithActions'; import { ServerListWithActions } from '@/components/server/ServerListWithActions';
import { WebSocketInitializer } from '@/components/websocket/WebSocketInitializer';
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification'; import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -14,62 +8,9 @@ export default async function DashboardPage() {
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>
<WebSocketInitializer openToken={session.openToken} />
<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>
)}
<form action={logoutAction}>
<button type="submit" 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"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
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>
<span className="ml-1 hidden sm:inline">Logout</span>
</button>
</form>
</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>
); );
} }

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

@@ -4,6 +4,7 @@ import { getServerConfigurations } from '@/lib/api/server/configuration';
import { ServerConfigurationTabs } from '@/components/server/ServerConfigurationTabs'; import { ServerConfigurationTabs } from '@/components/server/ServerConfigurationTabs';
import { ServerHeader } from '@/components/server/ServerHeader'; import { ServerHeader } from '@/components/server/ServerHeader';
import { getServerStatistics } from '@/lib/api/server/statistics'; import { getServerStatistics } from '@/lib/api/server/statistics';
import { getLeaderboard } from '@/lib/api/server/leaderboard';
import { subDays, formatISO } from 'date-fns'; import { subDays, formatISO } from 'date-fns';
import { UTCDate } from '@date-fns/utc'; import { UTCDate } from '@date-fns/utc';
@@ -19,25 +20,22 @@ export default async function ServerPage({ params }: ServerPageProps) {
const endDate = formatISO(today); const endDate = formatISO(today);
const startDate = formatISO(subDays(today, 30)); const startDate = formatISO(subDays(today, 30));
const [server, configurations, statistics] = await Promise.all([ const [server, configurations, statistics, leaderboard] = await Promise.all([
getServer(session.token!, id), getServer(session.token!, id),
getServerConfigurations(session.token!, id), getServerConfigurations(session.token!, id),
getServerStatistics(session.token!, id, startDate, endDate) getServerStatistics(session.token!, id, { startDate, endDate }),
getLeaderboard(session.token!, id)
]); ]);
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} /> <ServerConfigurationTabs
serverId={id}
<div className="mt-8"> configurations={configurations}
<ServerConfigurationTabs statistics={statistics}
serverId={id} leaderboard={leaderboard}
configurations={configurations} />
statistics={statistics}
/>
</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;
} }
} }

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

@@ -1,10 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { QueryProvider } from '@/components/providers/QueryProvider';
import { WebSocketProvider } from '@/lib/websocket/context';
import { SteamCMDProvider } from '@/lib/context/SteamCMDContext';
import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'ACC Server Manager', title: 'ACC Server Manager',
@@ -18,20 +13,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className="bg-gray-900 text-white antialiased"> <body className="bg-base text-primary antialiased">{children}</body>
<QueryProvider>
<WebSocketProvider
websocketURL={process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'ws://localhost:3000/ws'}
>
<SteamCMDProvider>
<ServerCreationPopupProvider>
{children}
<ServerCreationPopupContainer />
</ServerCreationPopupProvider>
</SteamCMDProvider>
</WebSocketProvider>
</QueryProvider>
</body>
</html> </html>
); );
} }

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

@@ -1,65 +1,18 @@
'use client'; import { Suspense } from 'react';
import LoginForm from '@/components/login/LoginForm';
import { checkHealth } from '@/lib/api/server/health';
export const dynamic = 'force-dynamic';
import { loginAction, LoginResult } from '@/lib/actions/auth'; export default async function LoginPage({
import { useActionState } from 'react'; searchParams
}: {
searchParams: Promise<{ expired: boolean | undefined }>;
}) {
const health = await checkHealth();
const initialState: LoginResult = {
message: '',
success: true
};
export default function LoginPage() {
const [state, formAction] = useActionState(loginAction, initialState);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4"> <Suspense fallback={<div>Loading...</div>}>
<div className="w-full max-w-md space-y-8 rounded-lg bg-gray-800 p-8 shadow-lg"> <LoginForm searchParams={searchParams} health={health} />
<div className="text-center"> </Suspense>
<h1 className="text-3xl font-bold text-white">ACC Server Manager</h1>
<p className="mt-2 text-gray-400">Sign in to manage your servers</p>
</div>
{state?.success ? null : (
<div className="rounded-md border border-red-700 bg-red-900/50 p-3 text-sm text-red-200">
{state?.message}
</div>
)}
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="username" className="mb-2 block text-sm font-medium text-gray-300">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="form-input w-full"
/>
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-gray-300">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="form-input w-full"
/>
</div>
<button
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"
>
Sign in
</button>
</form>
</div>
</div>
); );
} }

View File

@@ -1,7 +0,0 @@
import { logout } from '@/lib/auth/server';
import { redirect, RedirectType } from 'next/navigation';
export async function GET() {
await logout();
redirect('/login', RedirectType.replace);
}

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

@@ -7,6 +7,6 @@ export default async function HomePage() {
if (session.token && session.user) { if (session.token && session.user) {
redirect('/dashboard'); redirect('/dashboard');
} else { } else {
redirect('/logout'); redirect('/login');
} }
} }

View File

@@ -0,0 +1,256 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { getActivityLogAction, getGlobalActivityLogAction } 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>
);
}

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

@@ -1,106 +1,79 @@
'use client'; 'use client';
import { useState } from 'react'; import type { AssistRules } from '@/lib/schemas/config';
import type { AssistRules } from '@/lib/types/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps { interface AssistRulesEditorProps {
serverId: string; formData: AssistRules;
config: AssistRules; disabled?: boolean;
onFormDataChange: (data: AssistRules) => void;
} }
export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) { const assistFields = [
const [formData, setFormData] = useState<AssistRules>(config); {
const [restart, setRestart] = useState(true); key: 'stabilityControlLevelMax' as keyof AssistRules,
const [isSubmitting, setIsSubmitting] = useState(false); label: 'Stability Control Level Max',
type: 'number'
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { },
e.preventDefault(); {
setIsSubmitting(true); key: 'disableAutosteer' as keyof AssistRules,
label: 'Disable Autosteer',
const formDataObj = new FormData(); type: 'select'
Object.entries(formData).forEach(([key, value]) => { },
formDataObj.append(key, value.toString()); {
}); key: 'disableAutoLights' as keyof AssistRules,
if (restart) { label: 'Disable Auto Lights',
formDataObj.append('restart', 'on'); type: 'select'
} },
{
try { key: 'disableAutoWiper' as keyof AssistRules,
const result = await updateAssistRulesAction(serverId, formDataObj); label: 'Disable Auto Wiper',
if (!result.success) { type: 'select'
console.error('Failed to update assist rules:', result.message); },
} {
} finally { key: 'disableAutoEngineStart' as keyof AssistRules,
setIsSubmitting(false); label: 'Disable Auto Engine Start',
} type: 'select'
}; },
{
key: 'disableAutoPitLimiter' as keyof AssistRules,
label: 'Disable Auto Pit Limiter',
type: 'select'
},
{
key: 'disableAutoGear' as keyof AssistRules,
label: 'Disable Auto Gear',
type: 'select'
},
{
key: 'disableAutoClutch' as keyof AssistRules,
label: 'Disable Auto Clutch',
type: 'select'
},
{
key: 'disableIdealLine' as keyof AssistRules,
label: 'Disable Ideal Line',
type: 'select'
}
];
export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
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
})); });
}; };
const assistFields = [
{
key: 'stabilityControlLevelMax' as keyof AssistRules,
label: 'Stability Control Level Max',
type: 'number'
},
{
key: 'disableAutosteer' as keyof AssistRules,
label: 'Disable Autosteer',
type: 'select'
},
{
key: 'disableAutoLights' as keyof AssistRules,
label: 'Disable Auto Lights',
type: 'select'
},
{
key: 'disableAutoWiper' as keyof AssistRules,
label: 'Disable Auto Wiper',
type: 'select'
},
{
key: 'disableAutoEngineStart' as keyof AssistRules,
label: 'Disable Auto Engine Start',
type: 'select'
},
{
key: 'disableAutoPitLimiter' as keyof AssistRules,
label: 'Disable Auto Pit Limiter',
type: 'select'
},
{
key: 'disableAutoGear' as keyof AssistRules,
label: 'Disable Auto Gear',
type: 'select'
},
{
key: 'disableAutoClutch' as keyof AssistRules,
label: 'Disable Auto Clutch',
type: 'select'
},
{
key: 'disableIdealLine' as keyof AssistRules,
label: 'Disable Ideal Line',
type: 'select'
}
];
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>
); );
} }

93
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/types/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>
); );
} }

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

@@ -1,57 +1,32 @@
'use client'; 'use client';
import { useState } from 'react'; import type { EventConfig, Session } from '@/lib/schemas/config';
import type { EventConfig, Session } from '@/lib/types/config';
import { updateEventConfigAction } from '@/lib/actions/configuration';
interface EventConfigEditorProps { interface EventConfigEditorProps {
serverId: string; formData: EventConfig;
config: EventConfig; disabled?: boolean;
onFormDataChange: (data: EventConfig) => void;
} }
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) { const sessionTypes = [
const [formData, setFormData] = useState<EventConfig>(config); { value: 'P', label: 'Practice' },
const [restart, setRestart] = useState(true); { value: 'Q', label: 'Qualifying' },
const [isSubmitting, setIsSubmitting] = useState(false); { value: 'R', label: 'Race' }
];
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);
}
};
export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) {
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) => {
@@ -61,11 +36,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 = () => {
@@ -76,69 +47,63 @@ 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)
}));
}; };
const sessionTypes = [
{ value: 'P', label: 'Practice' },
{ value: 'Q', label: 'Qualifying' },
{ value: 'R', label: 'Race' }
];
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> <option value="barcelona">Barcelona</option>
<option value="brands_hatch">Brands Hatch</option> <option value="brands_hatch">Brands Hatch</option>
<option value="donington">Donington</option>
<option value="cota">COTA</option>
<option value="hungaroring">Hungaroring</option> <option value="hungaroring">Hungaroring</option>
<option value="imola">Imola</option> <option value="imola">Imola</option>
<option value="indianapolis">Indianapolis</option>
<option value="kyalami">Kyalami</option> <option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option> <option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option> <option value="misano">Misano</option>
<option value="monza">Monza</option> <option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option> <option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option> <option value="nurburgring">Nurburgring</option>
<option value="nurburgring_24h">Nurburgring 24h</option>
<option value="oulton_park">Oulton Park</option> <option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option> <option value="paul_ricard">Paul Ricard</option>
<option value="red_bull_ring">Red Bull Ring</option>
<option value="silverstone">Silverstone</option> <option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option> <option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option> <option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option> <option value="suzuka">Suzuka</option>
<option value="valencia">Valencia</option>
<option value="zandvoort">Zandvoort</option> <option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</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"
@@ -147,12 +112,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">
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"
@@ -161,12 +126,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"
@@ -175,12 +140,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"
@@ -191,17 +156,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"
@@ -211,12 +176,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"
@@ -227,10 +192,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"
@@ -241,12 +206,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"
@@ -256,14 +221,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>
@@ -271,14 +236,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>
@@ -288,28 +253,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>
@@ -317,14 +282,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}>
@@ -335,12 +300,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"
@@ -350,12 +315,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"
@@ -365,12 +330,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"
@@ -380,12 +345,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)
@@ -399,28 +364,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>
); );
} }

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

@@ -1,118 +1,88 @@
'use client'; 'use client';
import { useState } from 'react'; import type { EventRules } from '@/lib/schemas/config';
import type { EventRules } from '@/lib/types/config';
import { updateEventRulesAction } from '@/lib/actions/configuration';
interface EventRulesEditorProps { interface EventRulesEditorProps {
serverId: string; formData: EventRules;
config: EventRules; disabled?: boolean;
onFormDataChange: (data: EventRules) => void;
} }
export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) { const numberFields = [
const [formData, setFormData] = useState<EventRules>(config); {
const [restart, setRestart] = useState(true); key: 'qualifyStandingType' as keyof EventRules,
const [isSubmitting, setIsSubmitting] = useState(false); label: 'Qualify Standing Type',
min: -1,
max: 1
},
{
key: 'pitWindowLengthSec' as keyof EventRules,
label: 'Pit Window Length (seconds)',
min: -1
},
{
key: 'driverStintTimeSec' as keyof EventRules,
label: 'Driver Stint Time (seconds)',
min: -1
},
{
key: 'mandatoryPitstopCount' as keyof EventRules,
label: 'Mandatory Pitstop Count',
min: -1,
max: 5
},
{
key: 'maxTotalDrivingTime' as keyof EventRules,
label: 'Max Total Driving Time (seconds)',
min: -1
},
{
key: 'tyreSetCount' as keyof EventRules,
label: 'Tyre Set Count',
min: 0,
max: 50
}
];
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const booleanFields = [
e.preventDefault(); {
setIsSubmitting(true); key: 'isRefuellingAllowedInRace' as keyof EventRules,
label: 'Refuelling Allowed in Race'
},
{
key: 'isRefuellingTimeFixed' as keyof EventRules,
label: 'Refuelling Time Fixed'
},
{
key: 'isMandatoryPitstopRefuellingRequired' as keyof EventRules,
label: 'Mandatory Pitstop Refuelling Required'
},
{
key: 'isMandatoryPitstopTyreChangeRequired' as keyof EventRules,
label: 'Mandatory Pitstop Tyre Change Required'
},
{
key: 'isMandatoryPitstopSwapDriverRequired' as keyof EventRules,
label: 'Mandatory Pitstop Swap Driver Required'
}
];
const formDataObj = new FormData(); export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
Object.entries(formData).forEach(([key, value]) => { const handleInputChange = (key: keyof EventRules, value: number) => {
formDataObj.append(key, value.toString()); onFormDataChange({ ...formData, [key]: value });
});
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
}));
};
const numberFields = [
{
key: 'qualifyStandingType' as keyof EventRules,
label: 'Qualify Standing Type',
min: -1,
max: 1
},
{
key: 'pitWindowLengthSec' as keyof EventRules,
label: 'Pit Window Length (seconds)',
min: -1
},
{
key: 'driverStintTimeSec' as keyof EventRules,
label: 'Driver Stint Time (seconds)',
min: -1
},
{
key: 'mandatoryPitstopCount' as keyof EventRules,
label: 'Mandatory Pitstop Count',
min: -1,
max: 5
},
{
key: 'maxTotalDrivingTime' as keyof EventRules,
label: 'Max Total Driving Time (seconds)',
min: -1
},
{
key: 'tyreSetCount' as keyof EventRules,
label: 'Tyre Set Count',
min: 0,
max: 50
}
];
const booleanFields = [
{
key: 'isRefuellingAllowedInRace' as keyof EventRules,
label: 'Refuelling Allowed in Race'
},
{
key: 'isRefuellingTimeFixed' as keyof EventRules,
label: 'Refuelling Time Fixed'
},
{
key: 'isMandatoryPitstopRefuellingRequired' as keyof EventRules,
label: 'Mandatory Pitstop Refuelling Required'
},
{
key: 'isMandatoryPitstopTyreChangeRequired' as keyof EventRules,
label: 'Mandatory Pitstop Tyre Change Required'
},
{
key: 'isMandatoryPitstopSwapDriverRequired' as keyof EventRules,
label: 'Mandatory Pitstop Swap Driver Required'
}
];
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">
{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 +95,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 +115,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>
); );
} }

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

@@ -1,139 +1,109 @@
'use client'; 'use client';
import { useState } from 'react'; import type { ServerSettings } from '@/lib/schemas/config';
import type { ServerSettings } from '@/lib/types/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps { interface ServerSettingsEditorProps {
serverId: string; formData: ServerSettings;
config: ServerSettings; disabled?: boolean;
onFormDataChange: (data: ServerSettings) => void;
} }
export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) { const textFields = [
const [formData, setFormData] = useState<ServerSettings>(config); {
const [restart, setRestart] = useState(true); key: 'serverName' as keyof ServerSettings,
const [isSubmitting, setIsSubmitting] = useState(false); label: 'Server Name',
type: 'text'
},
{
key: 'adminPassword' as keyof ServerSettings,
label: 'Admin Password',
type: 'password'
},
{
key: 'password' as keyof ServerSettings,
label: 'Password',
type: 'password'
},
{
key: 'spectatorPassword' as keyof ServerSettings,
label: 'Spectator Password',
type: 'password'
},
{
key: 'centralEntryListPath' as keyof ServerSettings,
label: 'Central Entry List Path',
type: 'text'
}
];
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData(); const numberFields = [
Object.entries(formData).forEach(([key, value]) => { {
formDataObj.append(key, value.toString()); key: 'trackMedalsRequirement' as keyof ServerSettings,
}); label: 'Track Medals Requirement',
if (restart) { min: -1,
formDataObj.append('restart', 'on'); max: 3
} },
{
key: 'safetyRatingRequirement' as keyof ServerSettings,
label: 'Safety Rating Requirement',
min: -1,
max: 99
},
{
key: 'racecraftRatingRequirement' as keyof ServerSettings,
label: 'Racecraft Rating Requirement',
min: -1,
max: 99
},
{
key: 'maxCarSlots' as keyof ServerSettings,
label: 'Max Car Slots',
min: 1,
max: 30
}
];
try { const selectFields = [
const result = await updateServerSettingsAction(serverId, formDataObj); {
if (!result.success) { key: 'dumpLeaderboards' as keyof ServerSettings,
console.error('Failed to update server settings:', result.message); label: 'Dump Leaderboards'
} },
} finally { { key: 'isRaceLocked' as keyof ServerSettings, label: 'Race Locked' },
setIsSubmitting(false); {
} key: 'randomizeTrackWhenEmpty' as keyof ServerSettings,
}; label: 'Randomize Track When Empty'
},
{ key: 'allowAutoDQ' as keyof ServerSettings, label: 'Allow Auto DQ' },
{
key: 'shortFormationLap' as keyof ServerSettings,
label: 'Short Formation Lap'
},
{
key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects'
}
];
export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
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
}));
}; };
const textFields = [
{
key: 'serverName' as keyof ServerSettings,
label: 'Server Name',
type: 'text'
},
{
key: 'adminPassword' as keyof ServerSettings,
label: 'Admin Password',
type: 'password'
},
{
key: 'password' as keyof ServerSettings,
label: 'Password',
type: 'password'
},
{
key: 'spectatorPassword' as keyof ServerSettings,
label: 'Spectator Password',
type: 'password'
},
{
key: 'centralEntryListPath' as keyof ServerSettings,
label: 'Central Entry List Path',
type: 'text'
}
];
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
const numberFields = [
{
key: 'trackMedalsRequirement' as keyof ServerSettings,
label: 'Track Medals Requirement',
min: -1,
max: 3
},
{
key: 'safetyRatingRequirement' as keyof ServerSettings,
label: 'Safety Rating Requirement',
min: -1,
max: 99
},
{
key: 'racecraftRatingRequirement' as keyof ServerSettings,
label: 'Racecraft Rating Requirement',
min: -1,
max: 99
},
{
key: 'maxCarSlots' as keyof ServerSettings,
label: 'Max Car Slots',
min: 1,
max: 30
}
];
const selectFields = [
{
key: 'dumpLeaderboards' as keyof ServerSettings,
label: 'Dump Leaderboards'
},
{ key: 'isRaceLocked' as keyof ServerSettings, label: 'Race Locked' },
{
key: 'randomizeTrackWhenEmpty' as keyof ServerSettings,
label: 'Randomize Track When Empty'
},
{ key: 'allowAutoDQ' as keyof ServerSettings, label: 'Allow Auto DQ' },
{
key: 'shortFormationLap' as keyof ServerSettings,
label: 'Short Formation Lap'
},
{
key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects'
}
];
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 +112,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 +130,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 +152,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 +172,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 +190,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>
); );
} }

View File

@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Leaderboard, LeaderboardDriver, FlPoints } from '@/lib/schemas/leaderboard';
import { saveLeaderboardAction } from '@/lib/actions/leaderboard';
import { LeaderboardStandings } from './LeaderboardStandings';
import { DriversEditor } from './editors/DriversEditor';
import { PointsTableEditor } from './editors/PointsTableEditor';
import { FlPointsEditor } from './editors/FlPointsEditor';
import { TracksEditor } from './editors/TracksEditor';
type Section = 'standings' | 'drivers' | 'points' | 'fl' | 'tracks';
const sections: { id: Section; label: string }[] = [
{ id: 'standings', label: 'Standings' },
{ id: 'drivers', label: 'Drivers' },
{ id: 'points', label: 'Points Table' },
{ id: 'fl', label: 'FL Points' },
{ id: 'tracks', label: 'Tracks' }
];
interface LeaderboardManagerProps {
serverId: string;
initialData: Leaderboard;
}
// Extended type for internal UI state with full FL Points object
type LeaderboardDraft = Leaderboard & { flPointsData: FlPoints };
function toApiFormat(draft: LeaderboardDraft): Leaderboard {
return {
...draft,
flPoints: draft.flPointsData.points,
flColor: draft.flPointsData.color,
flTextColor: draft.flPointsData.textColor
};
}
function toUiFormat(api: Leaderboard): LeaderboardDraft {
return {
...api,
flPointsData: {
points: api.flPoints,
label: 'FL +' + api.flPoints,
color: api.flColor,
textColor: api.flTextColor,
priority: 3
}
};
}
function isDirty(a: LeaderboardDraft, b: LeaderboardDraft) {
return JSON.stringify(a) !== JSON.stringify(b);
}
export function LeaderboardManager({ serverId, initialData }: LeaderboardManagerProps) {
const [draft, setDraft] = useState<LeaderboardDraft>(toUiFormat(initialData));
const [saved, setSaved] = useState<LeaderboardDraft>(toUiFormat(initialData));
const [activeSection, setActiveSection] = useState<Section>('standings');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty = isDirty(draft, saved);
const handleDriversChange = (newDrivers: LeaderboardDriver[]) => {
const oldDriverIds = new Set(draft.drivers.map((d) => d.id));
// Remap tracks: keep results for drivers that still exist, remove results for deleted drivers
const newTracks = draft.tracks.map((t) => ({
...t,
// Filter existing results to only include drivers still in newDrivers, add DNS for new drivers
results: [
// Keep existing results for drivers still in the list
...t.results.filter((r) => newDrivers.some((d) => d.id === r.driverId)),
// Add DNS for new drivers
...newDrivers
.filter((d) => !oldDriverIds.has(d.id))
.map((d) => ({ driverId: d.id, score: 'DNS' as const }))
]
}));
setDraft({ ...draft, drivers: newDrivers, tracks: newTracks });
};
const handleSave = async () => {
setIsSubmitting(true);
setError(null);
try {
const apiData = toApiFormat(draft);
const result = await saveLeaderboardAction(serverId, apiData);
if (result.success) {
setSaved(draft);
} else {
setError(result.message ?? 'Unknown error');
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save');
} finally {
setIsSubmitting(false);
}
};
const renderSection = () => {
switch (activeSection) {
case 'standings':
return <LeaderboardStandings leaderboard={toApiFormat(draft)} />;
case 'drivers':
return <DriversEditor drivers={draft.drivers} onChange={handleDriversChange} />;
case 'points':
return (
<PointsTableEditor
pointsTable={draft.pointsTable}
onChange={(pointsTable) => setDraft({ ...draft, pointsTable })}
/>
);
case 'fl':
return (
<FlPointsEditor
flPoints={draft.flPointsData}
onChange={(flPointsData) => setDraft({ ...draft, flPointsData })}
/>
);
case 'tracks':
return (
<TracksEditor
tracks={draft.tracks}
drivers={draft.drivers}
pointsTable={draft.pointsTable}
onChange={(tracks) => setDraft({ ...draft, tracks })}
/>
);
}
};
return (
<div className="space-y-4">
{dirty && (
<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>
<div className="flex gap-2">
<button
onClick={() => setDraft(saved)}
className="rounded px-2 py-1 text-xs text-yellow hover:text-primary"
>
Discard
</button>
<button
onClick={handleSave}
disabled={isSubmitting}
className="rounded bg-yellow px-3 py-1 text-xs font-semibold text-base hover:opacity-90 disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)}
{error && (
<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">
{sections.map((s) => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeSection === s.id
? 'bg-blue text-white'
: 'bg-overlay text-secondary hover:bg-border'
}`}
>
{s.label}
</button>
))}
</div>
<div>{renderSection()}</div>
{!dirty && (
<div className="flex justify-end">
<Button variant="primary" size="md" onClick={handleSave} disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save Leaderboard'}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useMemo } from 'react';
import { Leaderboard, StandingsRow } from '@/lib/schemas/leaderboard';
interface LeaderboardStandingsProps {
leaderboard: Leaderboard;
}
export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps) {
const standings = useMemo<StandingsRow[]>(() => {
return leaderboard.drivers
.map((driver) => {
const trackResults = leaderboard.tracks.map(
(t) => t.results.find((r) => r.driverId === driver.id)?.score ?? 'DNS'
);
const fastestLapCount = leaderboard.tracks.filter(
(t) => t.fastestLapDriverId === driver.id
).length;
const totalPoints =
trackResults.reduce((sum: number, r) => sum + (typeof r === 'number' ? r : 0), 0) +
fastestLapCount * leaderboard.flPoints;
return { driver, driverId: driver.id, totalPoints, trackResults, fastestLapCount };
})
.sort((a, b) => b.totalPoints - a.totalPoints);
}, [leaderboard]);
if (leaderboard.drivers.length === 0) {
return (
<div className="py-12 text-center">
<div className="mb-2 text-4xl">🏆</div>
<p className="text-gray-400">No drivers yet. Add drivers to see standings.</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-max text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="py-2 pr-4 text-left text-xs font-semibold text-gray-400">Pos</th>
<th className="py-2 pr-4 text-left text-xs font-semibold text-gray-400">Driver</th>
{leaderboard.tracks.map((track, i) => (
<th
key={i}
className="px-2 py-2 text-center text-xs font-semibold text-gray-400"
title={track.name}
>
{track.name}
</th>
))}
<th className="px-2 py-2 text-center text-xs font-semibold text-gray-400">FL</th>
<th className="px-2 py-2 text-center text-xs font-semibold text-yellow-400">Pts</th>
</tr>
</thead>
<tbody>
{standings.map((row, pos) => (
<tr key={row.driver.id} className="border-b border-gray-700/50">
<td className="py-2 pr-4 text-gray-400">{pos + 1}</td>
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: row.driver.color }}
/>
<span className="font-medium text-white">{row.driver.name}</span>
<span className="text-xs text-gray-500">{row.driver.initials}</span>
</div>
</td>
{row.trackResults.map((result, ti) => {
const track = leaderboard.tracks[ti];
const isFl = track.fastestLapDriverId === row.driver.id;
return (
<td key={ti} className="px-2 py-2 text-center">
<span
className={`inline-block rounded px-1.5 py-0.5 text-xs font-semibold ${
typeof result === 'number'
? 'text-white'
: result === 'DNF'
? 'text-orange-400'
: 'text-gray-500'
}`}
>
{result}
{isFl && (
<span
className="ml-0.5 rounded px-0.5 text-xs"
style={{
backgroundColor: leaderboard.flColor,
color: leaderboard.flTextColor
}}
title="Fastest lap"
>
FL
</span>
)}
</span>
</td>
);
})}
<td className="px-2 py-2 text-center text-xs text-purple-400">
{row.fastestLapCount > 0 ? `×${row.fastestLapCount}` : '—'}
</td>
<td className="px-2 py-2 text-center font-bold text-yellow-400">{row.totalPoints}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { LeaderboardDriver } from '@/lib/schemas/leaderboard';
function randomColor(): string {
return '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
}
interface DriversEditorProps {
drivers: LeaderboardDriver[];
onChange: (drivers: LeaderboardDriver[]) => void;
}
export function DriversEditor({ drivers, onChange }: DriversEditorProps) {
const update = (index: number, patch: Partial<LeaderboardDriver>) => {
const next = drivers.map((d, i) => (i === index ? { ...d, ...patch } : d));
onChange(next);
};
const moveUp = (index: number) => {
if (index === 0) return;
const next = [...drivers];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
onChange(next);
};
const moveDown = (index: number) => {
if (index === drivers.length - 1) return;
const next = [...drivers];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
onChange(next);
};
const remove = (index: number) => {
onChange(drivers.filter((_, i) => i !== index));
};
const add = () => {
onChange([...drivers, { id: crypto.randomUUID(), name: 'New Driver', initials: 'ND', color: randomColor() }]);
};
return (
<div className="space-y-3">
{drivers.map((driver, i) => (
<div key={driver.id} className="flex items-center gap-3 rounded-lg bg-gray-700 p-3">
<div className="flex flex-col gap-1">
<button
onClick={() => moveUp(i)}
disabled={i === 0}
className="rounded px-1 py-0.5 text-xs text-gray-400 hover:text-white disabled:opacity-30"
title="Move up"
>
▲
</button>
<button
onClick={() => moveDown(i)}
disabled={i === drivers.length - 1}
className="rounded px-1 py-0.5 text-xs text-gray-400 hover:text-white disabled:opacity-30"
title="Move down"
>
▼
</button>
</div>
<div className="flex flex-1 flex-wrap gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Name</label>
<input
type="text"
value={driver.name}
onChange={(e) => update(i, { name: e.target.value })}
className="rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Initials</label>
<input
type="text"
value={driver.initials}
maxLength={10}
onChange={(e) => update(i, { initials: e.target.value })}
className="w-24 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={driver.color}
onChange={(e) => update(i, { color: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent"
/>
<input
type="text"
value={driver.color}
onChange={(e) => {
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) {
update(i, { color: e.target.value });
}
}}
className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white"
/>
</div>
</div>
</div>
<button
onClick={() => remove(i)}
className="rounded px-2 py-1 text-sm text-red-400 hover:bg-red-900/30 hover:text-red-300"
title="Remove driver"
>
✕
</button>
</div>
))}
<button
onClick={add}
className="w-full rounded-lg border border-dashed border-gray-600 py-2 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-300"
>
+ Add Driver
</button>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { FlPoints } from '@/lib/schemas/leaderboard';
interface FlPointsEditorProps {
flPoints: FlPoints;
onChange: (flPoints: FlPoints) => void;
}
export function FlPointsEditor({ flPoints, onChange }: FlPointsEditorProps) {
const update = (patch: Partial<FlPoints>) => onChange({ ...flPoints, ...patch });
return (
<div className="rounded-lg bg-gray-700 p-4">
<p className="mb-4 text-sm text-gray-400">
Points awarded to the driver who sets the fastest lap in each race.
</p>
<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Label</label>
<input
type="text"
value={flPoints.label}
onChange={(e) => update({ label: e.target.value })}
className="w-24 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Points</label>
<input
type="number"
min={0}
value={flPoints.points}
onChange={(e) => update({ points: parseInt(e.target.value) || 0 })}
className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Priority</label>
<input
type="number"
min={0}
value={flPoints.priority}
onChange={(e) => update({ priority: parseInt(e.target.value) || 0 })}
className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Bg Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={flPoints.color}
onChange={(e) => update({ color: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent"
/>
<input
type="text"
value={flPoints.color}
onChange={(e) => {
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update({ color: e.target.value });
}}
className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Text Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={flPoints.textColor}
onChange={(e) => update({ textColor: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent"
/>
<input
type="text"
value={flPoints.textColor}
onChange={(e) => {
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update({ textColor: e.target.value });
}}
className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Preview</label>
<span
className="rounded px-2 py-1 text-sm font-semibold"
style={{ backgroundColor: flPoints.color, color: flPoints.textColor }}
>
{flPoints.label}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { PointsEntry } from '@/lib/schemas/leaderboard';
interface PointsTableEditorProps {
pointsTable: PointsEntry[];
onChange: (pointsTable: PointsEntry[]) => void;
}
export function PointsTableEditor({ pointsTable, onChange }: PointsTableEditorProps) {
const update = (index: number, patch: Partial<PointsEntry>) => {
onChange(pointsTable.map((e, i) => (i === index ? { ...e, ...patch } : e)));
};
const remove = (index: number) => {
onChange(pointsTable.filter((_, i) => i !== index));
};
const add = () => {
const pos = pointsTable.length + 1;
onChange([
...pointsTable,
{ id: crypto.randomUUID(), points: 0, label: `P${pos}`, color: '#374151', textColor: '#ffffff', priority: pos }
]);
};
return (
<div className="space-y-3">
{pointsTable.map((entry, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg bg-gray-700 p-3">
<div className="flex flex-1 flex-wrap gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Label</label>
<input
type="text"
value={entry.label}
onChange={(e) => update(i, { label: e.target.value })}
className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Points</label>
<input
type="number"
min={0}
value={entry.points}
onChange={(e) => update(i, { points: parseInt(e.target.value) || 0 })}
className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Priority</label>
<input
type="number"
min={0}
value={entry.priority}
onChange={(e) => update(i, { priority: parseInt(e.target.value) || 0 })}
className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Bg Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={entry.color}
onChange={(e) => update(i, { color: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent"
/>
<input
type="text"
value={entry.color}
onChange={(e) => {
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update(i, { color: e.target.value });
}}
className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Text Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={entry.textColor}
onChange={(e) => update(i, { textColor: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent"
/>
<input
type="text"
value={entry.textColor}
onChange={(e) => {
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value))
update(i, { textColor: e.target.value });
}}
className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Preview</label>
<span
className="rounded px-2 py-1 text-sm font-semibold"
style={{ backgroundColor: entry.color, color: entry.textColor }}
>
{entry.label}
</span>
</div>
</div>
<button
onClick={() => remove(i)}
className="rounded px-2 py-1 text-sm text-red-400 hover:bg-red-900/30 hover:text-red-300"
title="Remove entry"
>
✕
</button>
</div>
))}
<button
onClick={add}
className="w-full rounded-lg border border-dashed border-gray-600 py-2 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-300"
>
+ Add Position
</button>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { LeaderboardDriver, LeaderboardTrack, PointsEntry, ResultValue } from '@/lib/schemas/leaderboard';
interface ResultInputProps {
value: ResultValue;
pointsTable: PointsEntry[];
onChange: (value: ResultValue) => void;
}
function ResultInput({ value, pointsTable, onChange }: ResultInputProps) {
const knownPoints = Array.from(new Set(pointsTable.map((e) => e.points))).sort((a, b) => b - a);
// Fallback 0 to DNS if 0 is not a valid points option
const displayValue = value === 0 && !knownPoints.includes(0) ? 'DNS' : value;
// Ensure the current numeric value is always included in options
const allPoints = typeof displayValue === 'number'
? Array.from(new Set([...knownPoints, displayValue])).sort((a, b) => b - a)
: knownPoints;
const handleChange = (raw: string) => {
if (raw === 'DNF') return onChange('DNF');
if (raw === 'DNS') return onChange('DNS');
const parsed = parseInt(raw);
onChange(isNaN(parsed) ? 0 : parsed);
};
return (
<select
value={String(displayValue)}
onChange={(e) => handleChange(e.target.value)}
className="w-full rounded bg-gray-600 px-1 py-1 text-xs text-white"
>
{allPoints.map((p) => (
<option key={p} value={String(p)}>
{p}
</option>
))}
<option value="DNF">DNF</option>
<option value="DNS">DNS</option>
</select>
);
}
interface TracksEditorProps {
tracks: LeaderboardTrack[];
drivers: LeaderboardDriver[];
pointsTable: PointsEntry[];
onChange: (tracks: LeaderboardTrack[]) => void;
}
export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksEditorProps) {
const updateTrack = (index: number, patch: Partial<LeaderboardTrack>) => {
onChange(tracks.map((t, i) => (i === index ? { ...t, ...patch } : t)));
};
const updateResult = (trackIndex: number, driverId: string, value: ResultValue) => {
const newResults = [...tracks[trackIndex].results];
const existingIndex = newResults.findIndex((r) => r.driverId === driverId);
if (existingIndex >= 0) {
newResults[existingIndex] = { driverId, score: value };
} else {
newResults.push({ driverId, score: value });
}
updateTrack(trackIndex, { results: newResults });
};
const removeTrack = (index: number) => {
onChange(tracks.filter((_, i) => i !== index));
};
const addTrack = () => {
onChange([
...tracks,
{
id: crypto.randomUUID(),
name: `Race ${tracks.length + 1}`,
results: drivers.map((d) => ({ driverId: d.id, score: 'DNS' as const })),
fastestLapDriverId: null
}
]);
};
return (
<div className="space-y-4">
{tracks.map((track, ti) => (
<div key={ti} className="rounded-lg bg-gray-700 p-4">
<div className="mb-3 flex items-center gap-3">
<input
type="text"
value={track.name}
onChange={(e) => updateTrack(ti, { name: e.target.value })}
className="flex-1 rounded bg-gray-600 px-2 py-1 text-sm font-semibold text-white"
placeholder="Track name"
/>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-400">FL Driver:</label>
<select
value={track.fastestLapDriverId ?? ''}
onChange={(e) => updateTrack(ti, { fastestLapDriverId: e.target.value || null })}
className="rounded bg-gray-600 px-2 py-1 text-sm text-white"
>
<option value="">—</option>
{drivers.map((d) => (
<option key={d.id} value={d.id}>
{d.initials} ({d.name})
</option>
))}
</select>
</div>
<button
onClick={() => removeTrack(ti)}
className="rounded px-2 py-1 text-sm text-red-400 hover:bg-red-900/30 hover:text-red-300"
title="Remove track"
>
✕
</button>
</div>
{drivers.length === 0 ? (
<p className="text-xs text-gray-500">Add drivers first to enter results.</p>
) : (
<div className="space-y-1">
{drivers.map((driver) => (
<div key={driver.id} className="flex items-center gap-3">
<div className="flex w-40 items-center gap-2">
<div
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: driver.color }}
/>
<span className="truncate text-sm text-gray-300">{driver.name}</span>
</div>
<div className="w-24">
<ResultInput
value={track.results.find((r) => r.driverId === driver.id)?.score ?? 'DNS'}
pointsTable={pointsTable}
onChange={(v) => updateResult(ti, driver.id, v)}
/>
</div>
</div>
))}
</div>
)}
</div>
))}
<button
onClick={addTrack}
className="w-full rounded-lg border border-dashed border-gray-600 py-2 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-300"
>
+ Add Track / Race
</button>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth';
import { use, useActionState, useEffect } from 'react';
import type { HealthStatus } from '@/lib/api/server/health';
import { Badge } from '@/components/ui/Badge';
const initialState: LoginResult = {
message: '',
success: true
};
export default function LoginForm({
searchParams,
health
}: {
searchParams: Promise<{ expired: boolean | undefined }>;
health: HealthStatus;
}) {
const params = use(searchParams);
const expired = params.expired;
useEffect(() => {
if (expired) {
clearExpiredSessionAction();
}
}, [expired]);
const [state, formAction] = useActionState(loginAction, initialState);
return (
<div className="flex min-h-screen items-center justify-center bg-base px-4">
<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">
<h1 className="text-xl font-bold text-primary">ACC Server Manager</h1>
<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>
{expired && (
<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.
</div>
)}
{state?.success ? null : (
<div className="rounded-md border border-red/30 bg-red-bg px-3 py-2 text-sm text-red">
{state?.message}
</div>
)}
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="username" className="mb-1.5 block text-sm font-medium text-secondary">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="form-input"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="form-input"
/>
</div>
<button
type="submit"
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
</button>
</form>
</div>
</div>
);
}

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

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { Role } from '@/lib/types'; 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>

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

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { User } from '@/lib/types'; 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>

271
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/types'; import type { Role, User } from '@/lib/schemas';
import { hasPermission } from '@/lib/types/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>

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

@@ -3,8 +3,9 @@
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/types/server'; import { Server } from '@/lib/schemas/server';
interface DeleteServerModalProps { interface DeleteServerModalProps {
isOpen: boolean; isOpen: boolean;
@@ -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>
); );

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

@@ -1,26 +1,35 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useState, useTransition } from 'react'; import { useTransition } from 'react';
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/types'; import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas';
import { User, hasPermission } from '@/lib/types/user';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
stopServerEventAction stopServerEventAction
} from '@/lib/actions/servers'; } from '@/lib/actions/servers';
import { DeleteServerModal } from './DeleteServerModal';
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;
user: User;
} }
export function ServerCard({ server, user }: ServerCardProps) { function statusBadgeVariant(status: ServiceStatus): 'green' | 'yellow' | 'gray' {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); if (status === ServiceStatus.Running) return 'green';
if (
[ServiceStatus.Starting, ServiceStatus.Restarting, ServiceStatus.Stopping].includes(status)
)
return 'yellow';
return 'gray';
}
export function ServerCard({ server }: ServerCardProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
const canDeleteServer = hasPermission(user, 'server.delete');
const startServer = () => const startServer = () =>
startTransition(async () => { startTransition(async () => {
await startServerEventAction(server.id); await startServerEventAction(server.id);
@@ -41,111 +50,71 @@ export function ServerCard({ server, user }: 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="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="block 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">
{canDeleteServer && (
<button
onClick={(e) => {
e.preventDefault();
setIsDeleteModalOpen(true);
}}
className="text-gray-400 hover:text-red-400"
title="Delete Server"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9zM4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 102 0v3a1 1 0 11-2 0V9zm4 0a1 1 0 10-2 0v3a1 1 0 002 0V9z"
clipRule="evenodd"
/>
</svg>
</button>
)}
<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 || '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>
<DeleteServerModal <div className="mt-3 flex items-center justify-between gap-2">
isOpen={isDeleteModalOpen} <Badge variant={statusBadgeVariant(server.status)}>
onClose={() => setIsDeleteModalOpen(false)} {serviceStatusToString(server.status)}
server={server} </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>
); );
} }

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

@@ -1,35 +1,93 @@
'use client'; 'use client';
import { Configurations, ServerTab } from '@/lib/types/config'; import { 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';
import { EventRulesEditor } from '@/components/configuration/EventRulesEditor'; 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 { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
import { ServerLogViewer } from '@/components/server/ServerLogViewer';
import { useState } from 'react'; import { useState } from 'react';
import { StateHistoryStats } from '@/lib/types'; import { StateHistoryStats } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps { interface ServerConfigurationTabsProps {
serverId: string; serverId: string;
configurations: Configurations; configurations: Configurations;
statistics: StateHistoryStats; statistics: StateHistoryStats;
leaderboard: Leaderboard;
} }
const tabs = [
{ id: ServerTab.statistics, name: 'Statistics', icon: '📊' },
{ id: ServerTab.configuration, name: 'Configuration', icon: '⚙️' },
{ id: ServerTab.assistRules, name: 'Assist Rules', icon: '🚗' },
{ id: ServerTab.event, name: 'Event Config', icon: '🏁' },
{ id: ServerTab.eventRules, name: 'Event Rules', icon: '📋' },
{ id: ServerTab.settings, name: 'Server Settings', 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,
statistics statistics,
leaderboard
}: ServerConfigurationTabsProps) { }: ServerConfigurationTabsProps) {
const [currentTab, setCurrentTab] = useState(ServerTab.statistics); const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const tabs = [
{ id: ServerTab.statistics, name: 'Statistics', icon: '📊' }, const [configData, setConfigData] = useState(configurations.configuration);
{ id: ServerTab.configuration, name: 'Configuration', icon: '⚙️' }, const [assistData, setAssistData] = useState(configurations.assistRules);
{ id: ServerTab.assistRules, name: 'Assist Rules', icon: '🚗' }, const [eventData, setEventData] = useState(configurations.event);
{ id: ServerTab.event, name: 'Event Config', icon: '🏁' }, const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
{ id: ServerTab.eventRules, name: 'Event Rules', icon: '📋' }, const [settingsData, setSettingsData] = useState(configurations.settings);
{ id: ServerTab.settings, name: 'Server Settings', icon: '🔧' }
]; const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const handleSaveAll = async () => {
setIsSubmitting(true);
setSaveError(null);
setSaveSuccess(false);
const result = await bulkUpdateConfigurationsAction(
serverId,
{
configuration: configData,
assistRules: assistData,
event: eventData,
eventRules: eventRulesData,
settings: settingsData
},
restart
);
if (result.success) {
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) {
@@ -37,19 +95,58 @@ export function ServerConfigurationTabs({
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={setConfigData}
/>
);
case ServerTab.assistRules: case ServerTab.assistRules:
return <AssistRulesEditor serverId={serverId} config={configurations.assistRules} />; return (
<AssistRulesEditor
formData={assistData}
disabled={isSubmitting}
onFormDataChange={setAssistData}
/>
);
case ServerTab.event: case ServerTab.event:
return <EventConfigEditor serverId={serverId} config={configurations.event} />; return (
<EventConfigEditor
formData={eventData}
disabled={isSubmitting}
onFormDataChange={setEventData}
/>
);
case ServerTab.eventRules: case ServerTab.eventRules:
return <EventRulesEditor serverId={serverId} config={configurations.eventRules} />; return (
<EventRulesEditor
formData={eventRulesData}
disabled={isSubmitting}
onFormDataChange={setEventRulesData}
/>
);
case ServerTab.settings: case ServerTab.settings:
return <ServerSettingsEditor serverId={serverId} config={configurations.settings} />; return (
<ServerSettingsEditor
formData={settingsData}
disabled={isSubmitting}
onFormDataChange={setSettingsData}
/>
);
case ServerTab.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 (
@@ -63,30 +160,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}
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>
); );
} }

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

@@ -2,13 +2,13 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context'; import { useWebSocket } from '@/lib/websocket/context';
import { import type {
WebSocketMessage, WebSocketMessage,
StepData, StepData,
SteamOutputData, SteamOutputData,
ErrorData, ErrorData,
CompleteData CompleteData
} from '@/lib/websocket/client'; } from '@/lib/schemas/websocket';
interface ServerCreationPopupProps { interface ServerCreationPopupProps {
serverId: string; serverId: string;
@@ -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>

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

@@ -4,10 +4,12 @@ import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext
import { ServerCreationPopup } from './ServerCreationPopup'; import { ServerCreationPopup } from './ServerCreationPopup';
import { useSteamCMD } from '@/lib/context/SteamCMDContext'; import { useSteamCMD } from '@/lib/context/SteamCMDContext';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
export function ServerCreationPopupContainer() { export function ServerCreationPopupContainer() {
const { popup, hidePopup } = useServerCreationPopup(); const { popup, hidePopup } = useServerCreationPopup();
const { dissociateServer } = useSteamCMD(); const { dissociateServer } = useSteamCMD();
const router = useRouter();
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
hidePopup(); hidePopup();
if (popup) return dissociateServer(popup.serverId); if (popup) return dissociateServer(popup.serverId);
@@ -17,7 +19,7 @@ export function ServerCreationPopupContainer() {
const handleComplete = (success: boolean) => { const handleComplete = (success: boolean) => {
if (success) { if (success) {
setTimeout(() => { setTimeout(() => {
window.location.reload(); router.refresh();
}, 2000); }, 2000);
} }
}; };

View File

@@ -1,432 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useWebSocket } from '@/lib/websocket/context';
import {
WebSocketMessage,
StepData,
SteamOutputData,
ErrorData,
CompleteData
} from '@/lib/websocket/client';
interface ServerCreationProgressClientProps {
serverId: string;
}
interface ConsoleEntry {
id: string;
timestamp: number;
type: 'step' | 'steam_output' | 'error' | 'complete';
content: string;
level: 'info' | 'success' | 'warning' | 'error';
}
interface StepStatus {
step: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
message: string;
}
const STEPS = [
{ key: 'validation', label: 'Validation' },
{ key: 'directory_creation', label: 'Directory Creation' },
{ key: 'steam_download', label: 'Steam Download' },
{ key: 'config_generation', label: 'Config Generation' },
{ key: 'service_creation', label: 'Service Creation' },
{ key: 'firewall_rules', label: 'Firewall Rules' },
{ key: 'database_save', label: 'Database Save' },
{ key: 'completed', label: 'Completed' }
];
export function ServerCreationProgressClient({ serverId }: ServerCreationProgressClientProps) {
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
const [steps, setSteps] = useState<Record<string, StepStatus>>({});
const [isCompleted, setIsCompleted] = useState(false);
const [completionResult, setCompletionResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
const {
associateWithServer,
addMessageHandler,
removeMessageHandler,
connectionStatus,
connectionError,
reconnect
} = useWebSocket();
const router = useRouter();
const consoleRef = useRef<HTMLDivElement>(null);
const addEntry = (entry: Omit<ConsoleEntry, 'id'>) => {
const newEntry = {
...entry,
id: `${Date.now()}-${Math.random()}`
};
setEntries((prev) => [...prev, newEntry]);
};
const scrollToBottom = () => {
if (consoleRef.current && !isMinimized) {
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
}
};
useEffect(() => {
scrollToBottom();
}, [entries, isMinimized]);
useEffect(() => {
if (serverId) {
associateWithServer(serverId);
}
}, [serverId, associateWithServer]);
useEffect(() => {
const handleMessage = (message: WebSocketMessage) => {
if (message.server_id !== serverId) return;
const timestamp = message.timestamp;
switch (message.type) {
case 'step': {
const data = message.data as StepData;
setSteps((prev) => ({
...prev,
[data.step]: {
step: data.step,
status: data.status,
message: data.message
}
}));
let level: ConsoleEntry['level'] = 'info';
if (data.status === 'completed') level = 'success';
else if (data.status === 'failed') level = 'error';
else if (data.status === 'in_progress') level = 'warning';
addEntry({
timestamp,
type: 'step',
content: `[${data.step.toUpperCase()}] ${data.message}${data.error ? ` - ${data.error}` : ''}`,
level
});
break;
}
case 'steam_output': {
const data = message.data as SteamOutputData;
addEntry({
timestamp,
type: 'steam_output',
content: data.output,
level: data.is_error ? 'error' : 'info'
});
break;
}
case 'error': {
const data = message.data as ErrorData;
addEntry({
timestamp,
type: 'error',
content: `ERROR: ${data.error}${data.details ? ` - ${data.details}` : ''}`,
level: 'error'
});
break;
}
case 'complete': {
const data = message.data as CompleteData;
setIsCompleted(true);
setCompletionResult({ success: data.success, message: data.message });
addEntry({
timestamp,
type: 'complete',
content: `COMPLETED: ${data.message}`,
level: data.success ? 'success' : 'error'
});
break;
}
}
};
addMessageHandler(handleMessage);
return () => {
removeMessageHandler(handleMessage);
};
}, [addMessageHandler, removeMessageHandler, serverId]);
const handleReturnToDashboard = () => {
router.push('/dashboard');
};
const handleReconnect = async () => {
try {
await reconnect();
} catch (error) {
console.error('Failed to reconnect:', error);
}
};
const getStepStatusIcon = (status: StepStatus['status']) => {
switch (status) {
case 'pending':
return '⏳';
case 'in_progress':
return '🔄';
case 'completed':
return '✅';
case 'failed':
return '❌';
}
};
const getEntryClassName = (level: ConsoleEntry['level']) => {
switch (level) {
case 'success':
return 'text-green-400';
case 'warning':
return 'text-yellow-400';
case 'error':
return 'text-red-400';
default:
return 'text-gray-300';
}
};
const getConnectionStatusColor = () => {
switch (connectionStatus) {
case 'connected':
return 'text-green-400';
case 'connecting':
return 'text-yellow-400';
case 'disconnected':
return 'text-gray-400';
case 'error':
return 'text-red-400';
}
};
const getConnectionStatusIcon = () => {
switch (connectionStatus) {
case 'connected':
return '🟢';
case 'connecting':
return '🟡';
case 'disconnected':
return '⚫';
case 'error':
return '🔴';
}
};
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* Header */}
<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">
<div className="flex items-center space-x-4">
<Link
href="/dashboard"
className="text-gray-400 hover:text-white"
title="Back to Dashboard"
>
<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-xl font-bold">Server Creation Progress</h1>
</div>
<div className="flex items-center space-x-4">
{/* Connection Status */}
<div className="flex items-center space-x-2">
<span className="text-lg">{getConnectionStatusIcon()}</span>
<span className={`text-sm ${getConnectionStatusColor()}`}>
{connectionStatus === 'connected' && 'Connected'}
{connectionStatus === 'connecting' && 'Connecting...'}
{connectionStatus === 'disconnected' && 'Disconnected'}
{connectionStatus === 'error' && 'Connection Error'}
</span>
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<button
onClick={handleReconnect}
className="rounded bg-blue-600 px-2 py-1 text-xs hover:bg-blue-700"
title="Reconnect to WebSocket"
>
Reconnect
</button>
)}
</div>
<button
onClick={() => setIsMinimized(!isMinimized)}
className="text-gray-400 hover:text-white"
title={isMinimized ? 'Expand Console' : 'Minimize Console'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{isMinimized ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 14l3-3 3 3"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 10l-3 3-3-3"
/>
)}
</svg>
</button>
{isCompleted && (
<button
onClick={handleReturnToDashboard}
className={`rounded-md px-4 py-2 text-sm font-medium ${
completionResult?.success
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
}`}
>
{completionResult?.success ? 'Return to Dashboard' : 'Back to Dashboard'}
</button>
)}
</div>
</div>
</header>
{/* Connection Error Banner */}
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<div className="border-l-4 border-red-400 bg-red-600 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-50" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-50">
<strong>WebSocket Connection Lost</strong> -
{connectionError
? ` ${connectionError}`
: ' Unable to receive real-time updates.'}{' '}
Progress may not be current.
</p>
</div>
</div>
<button
onClick={handleReconnect}
className="rounded bg-red-700 px-3 py-1 text-sm text-red-50 hover:bg-red-800"
>
Reconnect
</button>
</div>
</div>
)}
<main className="mx-auto max-w-[120rem] px-4 py-6 sm:px-6 lg:px-8">
<div className="space-y-6">
{/* Steps Progress */}
<div className="rounded-lg bg-gray-800 p-6">
<h2 className="mb-4 text-lg font-medium text-gray-300">Progress Steps</h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{STEPS.map(({ key, label }) => {
const stepStatus = steps[key];
return (
<div
key={key}
className={`flex items-center space-x-3 rounded-lg p-3 text-sm ${
stepStatus?.status === 'completed'
? 'bg-green-900/50 text-green-100'
: stepStatus?.status === 'in_progress'
? 'bg-yellow-900/50 text-yellow-100'
: stepStatus?.status === 'failed'
? 'bg-red-900/50 text-red-100'
: 'bg-gray-700 text-gray-300'
}`}
>
<span className="text-lg">
{getStepStatusIcon(stepStatus?.status || 'pending')}
</span>
<span className="font-medium">{label}</span>
</div>
);
})}
</div>
</div>
{/* Console Output */}
{!isMinimized && (
<div className="rounded-lg bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-700 p-4">
<h2 className="text-lg font-medium text-gray-300">Console Output</h2>
<div className="text-sm text-gray-400">{entries.length} log entries</div>
</div>
<div className="h-96 overflow-hidden rounded-b-lg bg-black p-4">
<div
ref={consoleRef}
className="h-full space-y-1 overflow-y-auto font-mono text-sm"
>
{entries.map((entry) => (
<div
key={entry.id}
className={`${getEntryClassName(entry.level)} leading-tight`}
>
<span className="text-xs text-gray-500">
{new Date(entry.timestamp * 1000).toLocaleTimeString()}
</span>{' '}
<span>{entry.content}</span>
</div>
))}
{entries.length === 0 && (
<div className="py-8 text-center text-gray-500">
Waiting for server creation to begin...
</div>
)}
</div>
</div>
</div>
)}
{isMinimized && (
<div className="rounded-lg bg-gray-800 p-4">
<div className="text-center text-gray-400">
Console minimized - click the expand button in the header to view output
</div>
</div>
)}
</div>
</main>
</div>
);
}

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

@@ -1,113 +1,93 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Server, getStatusColor, serviceStatusToString, ServiceStatus } from '@/lib/types/server'; import { Server, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server';
import { import {
startServerEventAction, startServerEventAction,
restartServerEventAction, restartServerEventAction,
stopServerEventAction stopServerEventAction
} from '@/lib/actions/servers'; } from '@/lib/actions/servers';
import { hasPermission, User } from '@/lib/schemas';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { DeleteServerModal } from './DeleteServerModal';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface ServerHeaderProps { interface ServerHeaderProps {
server: Server; server: Server;
user: User;
} }
export function ServerHeader({ server }: ServerHeaderProps) { export function ServerHeader({ server, user }: ServerHeaderProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const canDeleteServer = hasPermission(user, 'server.delete');
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 disabled = [
ServiceStatus.Restarting,
ServiceStatus.Starting,
ServiceStatus.Stopping,
ServiceStatus.Unknown
].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 className="flex items-center gap-2">
<Badge variant={
server.status === ServiceStatus.Running ? 'green' :
server.status === ServiceStatus.Stopped ? 'gray' : 'yellow'
}>
● {serviceStatusToString(server.status)}
</Badge>
<Button variant="ghost" size="sm" onClick={startServer} disabled={server.status === ServiceStatus.Running || disabled || isPending}>Start</Button>
<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>
{canDeleteServer && (
<Button variant="danger-solid" size="sm" onClick={() => setIsDeleteModalOpen(true)} disabled={disabled || isPending}>Remove</Button>
)}
</div>
</header>
<div className="flex flex-wrap gap-6 border-b border-border-muted px-5 py-3">
{[
{ label: 'Track', value: server.state?.track ?? 'N/A' },
{ 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="mt-6"> <DeleteServerModal
<div className="flex items-center justify-between"> isOpen={isDeleteModalOpen}
<div> onClose={() => setIsDeleteModalOpen(false)}
<h1 className="text-3xl font-bold text-white">{server.name}</h1> server={server}
<div className="mt-2 flex items-center"> />
<span </>
className={`inline-block h-3 w-3 rounded-full ${getStatusColor(server.status)} mr-3`}
/>
<span className="text-lg text-gray-300 capitalize">
{serviceStatusToString(server.status)}
</span>
</div>
</div>
<div className="flex space-x-3">
<form action={startServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Running}
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>
</form>
<form action={restartServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
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>
</form>
<form action={stopServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
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>
</form>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4">
<div>
<div className="text-sm text-gray-500">Current Track</div>
<div className="text-lg font-medium text-white">{server.state?.track || 'N/A'}</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>
); );
} }

59
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/types/server'; import { Server, ServiceStatus } from '@/lib/schemas/server';
import { User, hasPermission } from '@/lib/types/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} user={user} /> <div
))} className="hidden gap-3 md:grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 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,86 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Server, ServiceStatus } from '@/lib/schemas';
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 ?? '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,5 +1,5 @@
interface RecentSession { interface RecentSession {
id: number; id: string;
date: string; date: string;
type: string; type: string;
track: string; track: string;
@@ -29,51 +29,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">{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>

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

@@ -14,16 +14,14 @@ interface SessionTypesChartProps {
data: SessionCount[]; data: SessionCount[];
} }
export function SessionTypesChart({ data }: SessionTypesChartProps) { const colors = [
const colors = [ '#3fb950', // green
'#3b82f6', // blue '#58a6ff', // blue
'#10b981', // emerald '#f78166', // red
'#f59e0b', // amber '#d29922' // yellow
'#ef4444', // red ];
'#8b5cf6', // violet
'#06b6d4' // cyan
];
export function SessionTypesChart({ data }: SessionTypesChartProps) {
const chartData = { const chartData = {
labels: data.map((item) => item.name), labels: data.map((item) => item.name),
datasets: [ datasets: [
@@ -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>
); );
} }

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

@@ -1,6 +1,6 @@
'use client'; 'use client';
import type { StateHistoryStats } from '@/lib/types/statistics'; import type { StateHistoryStats } from '@/lib/schemas/statistics';
import { PlayerCountChart } from './PlayerCountChart'; import { PlayerCountChart } from './PlayerCountChart';
import { SessionTypesChart } from './SessionTypesChart'; import { SessionTypesChart } from './SessionTypesChart';
import { DailyActivityChart } from './DailyActivityChart'; import { DailyActivityChart } from './DailyActivityChart';
@@ -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;
} }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( const variantClasses: Record<ButtonVariant, string> = {
( primary: 'bg-btn-green border border-btn-green-hover text-white hover:bg-btn-green-hover',
{ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ghost: 'bg-transparent border border-border text-secondary hover:bg-overlay hover:text-primary',
ref 'danger-outline':
) => { 'bg-transparent border border-border text-red hover:border-red/40 hover:bg-red-bg',
const baseClasses = 'danger-solid': 'bg-overlay border border-red/20 text-red hover:bg-red-bg'
'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'; };
const variants = { const sizeClasses: Record<ButtonSize, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', sm: 'px-3 py-1.5 text-xs',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500', md: 'px-4 py-2 text-sm',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', logout: 'px-2 py-2 cursor-pointer hover:bg-red'
ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500' };
};
const sizes = { export function Button({
sm: 'px-3 py-1 text-sm', variant = 'ghost',
md: 'px-4 py-2 text-sm', size = 'md',
lg: 'px-6 py-3 text-base' className,
}; children,
disabled,
return ( ...props
<button }: ButtonProps) {
className={cn(baseClasses, variants[variant], sizes[size], className)} return (
disabled={disabled || isLoading} <button
ref={ref} {...props}
{...props} disabled={disabled}
> className={cn(
{isLoading && <LoadingSpinner size="sm" className="mr-2" />} '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',
{children} 'disabled:cursor-not-allowed disabled:opacity-40',
</button> 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

View File

@@ -0,0 +1,29 @@
'use client';
import { logoutAction } from '@/lib/actions/auth';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
export default function LogoutButton() {
const [, formAction] = useActionState(logoutAction, null);
return (
<form action={formAction}>
<Button type="submit" title="Logout" variant="ghost" size="logout">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
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>
</Button>
</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'
};
}
}

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

@@ -2,7 +2,8 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { loginUser, getOpenToken } from '@/lib/api/server/auth'; import { loginUser, getOpenToken } from '@/lib/api/server/auth';
import { login } from '@/lib/auth/server'; import { login, logout } from '@/lib/auth/server';
import { loginSchema, loginResponseSchema } from '../schemas';
export type LoginResult = { export type LoginResult = {
success: boolean; success: boolean;
@@ -14,18 +15,19 @@ export async function loginAction(prevState: LoginResult, formData: FormData) {
const username = formData.get('username') as string; const username = formData.get('username') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
if (!username || !password) { const loginData = loginSchema.safeParse({ username, password });
if (!loginData.success) {
return { return {
success: false, success: false,
message: 'Username and password are required' message: loginData.error.message
}; };
} }
const result = await loginUser(username, password); const result = loginResponseSchema.safeParse(await loginUser(loginData.data));
if (result.token && result.user) { if (result.success) {
const openToken = await getOpenToken(result.token); const openToken = await getOpenToken(result.data.token);
await login(result.token, result.user, openToken); await login(result.data.token, result.data.user, openToken);
} else { } else {
return { return {
success: false, success: false,
@@ -43,5 +45,9 @@ export async function loginAction(prevState: LoginResult, formData: FormData) {
} }
export async function logoutAction() { export async function logoutAction() {
redirect('/logout'); await logout();
}
export async function clearExpiredSessionAction() {
await logout();
} }

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

@@ -2,15 +2,24 @@
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 { ConfigFile } from '@/lib/types/config'; import {
assistRulesSchema,
ConfigFile,
configurationsSchema,
eventConfigSchema,
eventRulesSchema,
serverSettingsSchema
} from '@/lib/schemas/config';
import type { import type {
Configuration, Configuration,
AssistRules, AssistRules,
Configurations,
EventConfig, EventConfig,
EventRules, EventRules,
ServerSettings ServerSettings
} from '@/lib/types/config'; } from '@/lib/schemas/config';
import { boolToInt } from '@/lib/utils';
export async function updateConfigurationAction(serverId: string, formData: FormData) { export async function updateConfigurationAction(serverId: string, formData: FormData) {
try { try {
@@ -49,7 +58,7 @@ export async function updateAssistRulesAction(serverId: string, formData: FormDa
const session = await requireAuth(); const session = await requireAuth();
const restart = formData.get('restart') === 'on'; const restart = formData.get('restart') === 'on';
const config: AssistRules = { const rawConfig: AssistRules = {
stabilityControlLevelMax: parseInt(formData.get('stabilityControlLevelMax') as string), stabilityControlLevelMax: parseInt(formData.get('stabilityControlLevelMax') as string),
disableAutosteer: parseInt(formData.get('disableAutosteer') as string), disableAutosteer: parseInt(formData.get('disableAutosteer') as string),
disableAutoLights: parseInt(formData.get('disableAutoLights') as string), disableAutoLights: parseInt(formData.get('disableAutoLights') as string),
@@ -61,11 +70,16 @@ export async function updateAssistRulesAction(serverId: string, formData: FormDa
disableIdealLine: parseInt(formData.get('disableIdealLine') as string) disableIdealLine: parseInt(formData.get('disableIdealLine') as string)
}; };
const config = assistRulesSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration( await updateServerConfiguration(
session.token!, session.token!,
serverId, serverId,
ConfigFile.assistRules, ConfigFile.assistRules,
config, config.data,
restart restart
); );
revalidatePath(`/dashboard/server/${serverId}`); revalidatePath(`/dashboard/server/${serverId}`);
@@ -84,7 +98,7 @@ export async function updateServerSettingsAction(serverId: string, formData: For
const session = await requireAuth(); const session = await requireAuth();
const restart = formData.get('restart') === 'on'; const restart = formData.get('restart') === 'on';
const config: ServerSettings = { const rawConfig: ServerSettings = {
serverName: formData.get('serverName') as string, serverName: formData.get('serverName') as string,
adminPassword: formData.get('adminPassword') as string, adminPassword: formData.get('adminPassword') as string,
carGroup: formData.get('carGroup') as string, carGroup: formData.get('carGroup') as string,
@@ -103,8 +117,18 @@ export async function updateServerSettingsAction(serverId: string, formData: For
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)
}; };
const config = serverSettingsSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(session.token!, serverId, ConfigFile.settings, config, restart); await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.settings,
config.data,
restart
);
revalidatePath(`/dashboard/server/${serverId}`); revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Server settings updated successfully' }; return { success: true, message: 'Server settings updated successfully' };
@@ -124,7 +148,7 @@ export async function updateEventConfigAction(serverId: string, formData: FormDa
const sessionsData = formData.get('sessions') as string; const sessionsData = formData.get('sessions') as string;
const sessions = sessionsData ? JSON.parse(sessionsData) : []; const sessions = sessionsData ? JSON.parse(sessionsData) : [];
const config: EventConfig = { const rawConfig: EventConfig = {
track: formData.get('track') as string, track: formData.get('track') as string,
preRaceWaitingTimeSeconds: parseInt(formData.get('preRaceWaitingTimeSeconds') as string), preRaceWaitingTimeSeconds: parseInt(formData.get('preRaceWaitingTimeSeconds') as string),
sessionOverTimeSeconds: parseInt(formData.get('sessionOverTimeSeconds') as string), sessionOverTimeSeconds: parseInt(formData.get('sessionOverTimeSeconds') as string),
@@ -140,8 +164,18 @@ export async function updateEventConfigAction(serverId: string, formData: FormDa
), ),
sessions sessions
}; };
const config = eventConfigSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(session.token!, serverId, ConfigFile.event, config, restart); await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.event,
config.data,
restart
);
revalidatePath(`/dashboard/server/${serverId}`); revalidatePath(`/dashboard/server/${serverId}`);
return { return {
@@ -161,28 +195,33 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
const session = await requireAuth(); const session = await requireAuth();
const restart = formData.get('restart') === 'on'; const restart = formData.get('restart') === 'on';
const config: EventRules = { const rawConfig: EventRules = {
qualifyStandingType: parseInt(formData.get('qualifyStandingType') as string), qualifyStandingType: parseInt(formData.get('qualifyStandingType') as string),
pitWindowLengthSec: parseInt(formData.get('pitWindowLengthSec') as string), pitWindowLengthSec: parseInt(formData.get('pitWindowLengthSec') as string),
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),
isRefuellingAllowedInRace: formData.get('isRefuellingAllowedInRace') === 'true', isRefuellingAllowedInRace: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'),
isRefuellingTimeFixed: formData.get('isRefuellingTimeFixed') === 'true', isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'),
isMandatoryPitstopRefuellingRequired: isMandatoryPitstopRefuellingRequired:
formData.get('isMandatoryPitstopRefuellingRequired') === 'true', boolToInt(formData.get('isMandatoryPitstopRefuellingRequired') === 'true'),
isMandatoryPitstopTyreChangeRequired: isMandatoryPitstopTyreChangeRequired:
formData.get('isMandatoryPitstopTyreChangeRequired') === 'true', boolToInt(formData.get('isMandatoryPitstopTyreChangeRequired') === 'true'),
isMandatoryPitstopSwapDriverRequired: isMandatoryPitstopSwapDriverRequired:
formData.get('isMandatoryPitstopSwapDriverRequired') === 'true', boolToInt(formData.get('isMandatoryPitstopSwapDriverRequired') === 'true'),
tyreSetCount: parseInt(formData.get('tyreSetCount') as string) tyreSetCount: parseInt(formData.get('tyreSetCount') as string)
}; };
const config = eventRulesSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration( await updateServerConfiguration(
session.token!, session.token!,
serverId, serverId,
ConfigFile.eventRules, ConfigFile.eventRules,
config, config.data,
restart restart
); );
revalidatePath(`/dashboard/server/${serverId}`); revalidatePath(`/dashboard/server/${serverId}`);
@@ -195,3 +234,28 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
}; };
} }
} }
export async function bulkUpdateConfigurationsAction(
serverId: string,
configs: Configurations,
restart: boolean
) {
try {
const session = await requireAuth();
const validated = configurationsSchema.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'
};
}
}

24
src/lib/actions/leaderboard.ts Executable file
View File

@@ -0,0 +1,24 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { fetchServerAPI } from '@/lib/api/server/base';
import { leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard';
export async function saveLeaderboardAction(serverId: string, data: Leaderboard) {
try {
const session = await requireAuth();
const validated = leaderboardSchema.safeParse(data);
if (!validated.success) {
return { success: false, message: validated.error.message };
}
await fetchServerAPI(`/server/${serverId}/leaderboard`, session.token!, 'PUT', validated.data);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Leaderboard saved successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to save leaderboard'
};
}
}

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

@@ -3,6 +3,7 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { createUser, deleteUser } from '@/lib/api/server/membership'; import { createUser, deleteUser } from '@/lib/api/server/membership';
import { userCreateSchema } from '../schemas';
export async function createUserAction(formData: FormData) { export async function createUserAction(formData: FormData) {
try { try {
@@ -11,7 +12,13 @@ export async function createUserAction(formData: FormData) {
const password = formData.get('password') as string; const password = formData.get('password') as string;
const role = formData.get('role') as string; const role = formData.get('role') as string;
await createUser(session.token!, { username, password, role }); const rawData = { username, password, role };
const data = userCreateSchema.safeParse(rawData);
if (!data.success) {
return { success: false, message: data.error.message };
}
await createUser(session.token!, data.data);
revalidatePath('/dashboard/membership'); revalidatePath('/dashboard/membership');
return { success: true, message: 'User created successfully' }; return { success: true, message: 'User created successfully' };

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

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

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { SessionData } from '@/lib/session/config';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
export type ClientApiResponse<T> = { export type ClientApiResponse<T> = {
@@ -8,6 +10,14 @@ export type ClientApiResponse<T> = {
message?: string; message?: string;
}; };
const getSession = async (): Promise<SessionData | null> => {
const response = await fetch('/api/session');
if (response.ok) {
return await response.json();
}
return null;
};
export async function fetchClientAPI<T>( export async function fetchClientAPI<T>(
endpoint: string, endpoint: string,
method: string = 'GET', method: string = 'GET',
@@ -15,13 +25,11 @@ export async function fetchClientAPI<T>(
customToken?: string customToken?: string
): Promise<ClientApiResponse<T>> { ): Promise<ClientApiResponse<T>> {
let token = customToken; let token = customToken;
let session: SessionData | null = null;
if (!token) { if (!token) {
const response = await fetch('/api/session'); session = await getSession();
if (response.ok) { token = session?.openToken;
const session = await response.json();
token = session.openToken;
}
if (!token) { if (!token) {
throw new Error('No authentication token available'); throw new Error('No authentication token available');
@@ -41,7 +49,7 @@ export async function fetchClientAPI<T>(
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
window.location.href = '/logout'; window.location.href = '/login?expired=true';
return { error: 'unauthorized' }; return { error: 'unauthorized' };
} }
throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`); throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`);

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);
}

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

@@ -1,17 +1,26 @@
import { User } from '@/lib/types'; import {
Login,
LoginResponse,
loginResponseSchema,
loginSchema,
loginTokenSchema,
User,
userSchema
} from '@/lib/schemas';
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
const authRoute = '/auth'; const authRoute = '/auth';
export async function loginUser(username: string, password: string) { export async function loginUser(login: Login): Promise<LoginResponse> {
const validatedLogin = loginSchema.parse(login);
const response = await fetch(`${BASE_URL}${authRoute}/login`, { const response = await fetch(`${BASE_URL}${authRoute}/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password }) body: JSON.stringify(validatedLogin)
}); });
if (!response.ok) { if (!response.ok) {
@@ -22,16 +31,18 @@ export async function loginUser(username: string, password: string) {
throw new Error(`Login failed: ${response.statusText}`); throw new Error(`Login failed: ${response.statusText}`);
} }
const { token } = await response.json(); const rawData = await response.json();
const { token } = loginTokenSchema.parse(rawData);
const userResponse = await getCurrentUser(token); const userResponse = await getCurrentUser(token);
return { token, user: userResponse }; return loginResponseSchema.parse({ token, user: userResponse });
} }
export async function getCurrentUser(token: string): Promise<User> { export async function getCurrentUser(token: string): Promise<User> {
const response = await fetchServerAPI<User>(`${authRoute}/me`, token); const response = await fetchServerAPI<User>(`${authRoute}/me`, token);
return response.data!; return userSchema.parse(response.data);
} }
export async function getOpenToken(token: string): Promise<string> { export async function getOpenToken(token: string): Promise<string> {
@@ -40,5 +51,5 @@ export async function getOpenToken(token: string): Promise<string> {
token, token,
'POST' 'POST'
); );
return response.data!.token; return loginTokenSchema.parse(response.data).token;
} }

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

@@ -1,5 +1,3 @@
import { redirect } from 'next/navigation';
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
type ApiResponse<T> = { type ApiResponse<T> = {
@@ -8,6 +6,8 @@ type ApiResponse<T> = {
message?: string; message?: string;
}; };
import { redirect } from 'next/navigation';
export async function fetchServerAPI<T>( export async function fetchServerAPI<T>(
endpoint: string, endpoint: string,
token: string, token: string,
@@ -28,12 +28,9 @@ export async function fetchServerAPI<T>(
if (!response.ok) { if (!response.ok) {
if (response.status == 401) { if (response.status == 401) {
redirect('/logout'); redirect('/login?expired=true');
return { error: 'unauthorized' };
} }
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')) {

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

@@ -1,5 +1,21 @@
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import type { Configurations, ConfigFile, Config } from '@/lib/types/config'; import {
type Configurations,
type Config,
ConfigFile,
configurationsSchema,
configSchemaMap
} from '@/lib/schemas/config';
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';
@@ -8,7 +24,15 @@ export async function getServerConfigurations(
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 response.data!; return configurationsSchema.parse(response.data);
}
export function validateConfig(
configType: ConfigFile,
data: unknown
): z.infer<(typeof configSchemaMap)[typeof configType]> {
const schema = configSchemaMap[configType];
return schema.parse(data);
} }
export async function getServerConfiguration( export async function getServerConfiguration(
@@ -20,7 +44,7 @@ export async function getServerConfiguration(
`${serverRoute}/${serverId}/config/${configType}`, `${serverRoute}/${serverId}/config/${configType}`,
token token
); );
return response.data!; return validateConfig(configType, response.data);
} }
export async function updateServerConfiguration( export async function updateServerConfiguration(
@@ -30,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}`, token, 'PUT', { await fetchServerAPI(
...config, `${serverRoute}/${serverId}/config/${configType}?override=true&restart=${restart}`,
restart token,
}); 'PUT',
{
...validateConfig(configType, config)
}
);
}
export async function bulkUpdateServerConfigurations(
token: string,
serverId: string,
configs: 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 };
}
}

View File

@@ -0,0 +1,8 @@
import { fetchServerAPI } from './base';
import { emptyLeaderboard, leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard';
export async function getLeaderboard(token: string, serverId: string): Promise<Leaderboard> {
const response = await fetchServerAPI<Leaderboard>(`/server/${serverId}/leaderboard`, token);
const parsed = leaderboardSchema.safeParse(response.data);
return parsed.success ? parsed.data : emptyLeaderboard;
}

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

@@ -1,21 +1,32 @@
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import { Track, CarModel, CupCategory, DriverCategory, SessionType } from '@/lib/types'; import {
Track,
CarModel,
CupCategory,
DriverCategory,
SessionType,
trackSchema,
carModelSchema,
cupCategorySchema,
driverCategorySchema,
sessionTypeSchema
} from '@/lib/schemas';
const lookupRoute = '/lookup'; const lookupRoute = '/lookup';
export async function getTracks(token: string): Promise<Track[]> { export async function getTracks(token: string): Promise<Track[]> {
const response = await fetchServerAPI<Track[]>(`${lookupRoute}/tracks`, token); const response = await fetchServerAPI<Track[]>(`${lookupRoute}/tracks`, token);
return response.data!; return trackSchema.array().parse(response.data);
} }
export async function getCarModels(token: string): Promise<CarModel[]> { export async function getCarModels(token: string): Promise<CarModel[]> {
const response = await fetchServerAPI<CarModel[]>(`${lookupRoute}/car-models`, token); const response = await fetchServerAPI<CarModel[]>(`${lookupRoute}/car-models`, token);
return response.data!; return carModelSchema.array().parse(response.data);
} }
export async function getCupCategories(token: string): Promise<CupCategory[]> { export async function getCupCategories(token: string): Promise<CupCategory[]> {
const response = await fetchServerAPI<CupCategory[]>(`${lookupRoute}/cup-categories`, token); const response = await fetchServerAPI<CupCategory[]>(`${lookupRoute}/cup-categories`, token);
return response.data!; return cupCategorySchema.array().parse(response.data);
} }
export async function getDriverCategories(token: string): Promise<DriverCategory[]> { export async function getDriverCategories(token: string): Promise<DriverCategory[]> {
@@ -23,10 +34,10 @@ export async function getDriverCategories(token: string): Promise<DriverCategory
`${lookupRoute}/driver-categories`, `${lookupRoute}/driver-categories`,
token token
); );
return response.data!; return driverCategorySchema.array().parse(response.data);
} }
export async function getSessionTypes(token: string): Promise<SessionType[]> { export async function getSessionTypes(token: string): Promise<SessionType[]> {
const response = await fetchServerAPI<SessionType[]>(`${lookupRoute}/session-types`, token); const response = await fetchServerAPI<SessionType[]>(`${lookupRoute}/session-types`, token);
return response.data!; return sessionTypeSchema.array().parse(response.data);
} }

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

@@ -1,5 +1,5 @@
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import { User, Role } from '@/lib/types'; import { User, Role, userSchema, UserCreate, userCreateSchema, roleSchema } from '@/lib/schemas';
export interface UserListParams { export interface UserListParams {
username?: string; username?: string;
@@ -25,27 +25,29 @@ export async function getUsers(token: string, params: UserListParams = {}): Prom
const endpoint = `${membershipRoute}${queryString ? `?${queryString}` : ''}`; const endpoint = `${membershipRoute}${queryString ? `?${queryString}` : ''}`;
const response = await fetchServerAPI<User[]>(endpoint, token); const response = await fetchServerAPI<User[]>(endpoint, token);
return response.data!; return userSchema.array().parse(response.data);
} }
export async function createUser( export async function createUser(token: string, userData: UserCreate): Promise<void> {
token: string, await fetchServerAPI(membershipRoute, token, 'POST', userCreateSchema.parse(userData));
userData: { username: string; password: string; role: string }
): Promise<void> {
await fetchServerAPI(membershipRoute, token, 'POST', userData);
} }
export async function getUserById(token: string, userId: string): Promise<User> { export async function getUserById(token: string, userId: string): Promise<User> {
const response = await fetchServerAPI<User>(`${membershipRoute}/${userId}`, token); const response = await fetchServerAPI<User>(`${membershipRoute}/${userId}`, token);
return response.data!; return userSchema.parse(response.data);
} }
export async function updateUser( export async function updateUser(
token: string, token: string,
userId: string, userId: string,
userData: Partial<User> userData: Partial<UserCreate>
): Promise<void> { ): Promise<void> {
await fetchServerAPI(`${membershipRoute}/${userId}`, token, 'PUT', userData); await fetchServerAPI(
`${membershipRoute}/${userId}`,
token,
'PUT',
userCreateSchema.parse(userData)
);
} }
export async function deleteUser(token: string, userId: string): Promise<void> { export async function deleteUser(token: string, userId: string): Promise<void> {
@@ -54,5 +56,5 @@ export async function deleteUser(token: string, userId: string): Promise<void> {
export async function getRoles(token: string): Promise<Role[]> { export async function getRoles(token: string): Promise<Role[]> {
const response = await fetchServerAPI<Role[]>(`${membershipRoute}/roles`, token); const response = await fetchServerAPI<Role[]>(`${membershipRoute}/roles`, token);
return response.data!; return roleSchema.array().parse(response.data);
} }

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 ?? [];
}

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

@@ -1,16 +1,16 @@
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import { Server, ServiceStatus } from '@/lib/types/server'; import { Server, serverSchema, ServiceStatus, serviceStatusSchema } from '@/lib/schemas/server';
const serverRoute = '/server'; const serverRoute = '/server';
export async function getServers(token: string): Promise<Server[]> { export async function getServers(token: string): Promise<Server[]> {
const response = await fetchServerAPI<Server[]>(serverRoute, token); const response = await fetchServerAPI<Server[]>(serverRoute, token);
return response.data!; return serverSchema.array().parse(response.data);
} }
export async function getServer(token: string, serverId: string): Promise<Server> { export async function getServer(token: string, serverId: string): Promise<Server> {
const response = await fetchServerAPI<Server>(`${serverRoute}/${serverId}`, token); const response = await fetchServerAPI<Server>(`${serverRoute}/${serverId}`, token);
return response.data!; return serverSchema.parse(response.data);
} }
export async function restartService(token: string, serverId: string): Promise<void> { export async function restartService(token: string, serverId: string): Promise<void> {
@@ -27,12 +27,12 @@ export async function stopService(token: string, serverId: string): Promise<void
export async function getServiceStatus(token: string, serverId: string): Promise<ServiceStatus> { export async function getServiceStatus(token: string, serverId: string): Promise<ServiceStatus> {
const response = await fetchServerAPI<ServiceStatus>(`${serverRoute}/${serverId}/service`, token); const response = await fetchServerAPI<ServiceStatus>(`${serverRoute}/${serverId}/service`, token);
return response.data!; return serviceStatusSchema.parse(response.data);
} }
export async function createServer(token: string, name: string): Promise<Server> { export async function createServer(token: string, name: string): Promise<Server> {
const response = await fetchServerAPI<Server>(serverRoute, token, 'POST', { name }); const response = await fetchServerAPI<Server>(serverRoute, token, 'POST', { name });
return response.data!; return serverSchema.parse(response.data);
} }
export async function deleteServer(token: string, serverId: string): Promise<void> { export async function deleteServer(token: string, serverId: string): Promise<void> {

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

@@ -1,17 +1,22 @@
import { fetchServerAPI } from './base'; import { fetchServerAPI } from './base';
import type { StateHistoryStats } from '@/lib/types/statistics'; import {
StateHistoryStatsFilter,
stateHistoryStatsFilterSchema,
stateHistoryStatsSchema,
type StateHistoryStats
} from '@/lib/schemas/statistics';
const serverRoute = '/server'; const serverRoute = '/server';
export async function getServerStatistics( export async function getServerStatistics(
token: string, token: string,
serverId: string, serverId: string,
startDate: string, filters: StateHistoryStatsFilter
endDate: string
): Promise<StateHistoryStats> { ): Promise<StateHistoryStats> {
const { startDate, endDate } = stateHistoryStatsFilterSchema.parse(filters);
const response = await fetchServerAPI<StateHistoryStats>( const response = await fetchServerAPI<StateHistoryStats>(
`${serverRoute}/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`, `${serverRoute}/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`,
token token
); );
return response.data!; return stateHistoryStatsSchema.parse(response.data);
} }

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

@@ -8,11 +8,11 @@ export async function getSession() {
return session; return session;
} }
export async function requireAuth() { export async function requireAuth(skipRedirect?: boolean) {
const session = await getSession(); const session = await getSession();
if (!session.token || !session.user) { if (!skipRedirect && (!session.token || !session.user)) {
redirect('/logout'); redirect('/login?expired=true');
} }
return session; return session;
@@ -27,6 +27,7 @@ export async function login(token: string, user: SessionData['user'], openToken?
} }
export async function logout() { export async function logout() {
'use server';
const session = await getSession(); const session = await getSession();
session.destroy(); session.destroy();
} }

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

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

@@ -2,7 +2,7 @@
import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context'; import { useWebSocket } from '@/lib/websocket/context';
import { WebSocketMessage, StepData } from '@/lib/websocket/client'; import type { WebSocketMessage, StepData } from '@/lib/schemas/websocket';
interface SteamCMDContextType { interface SteamCMDContextType {
isSteamCMDRunning: boolean; isSteamCMDRunning: boolean;

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