migrate to nextjs

This commit is contained in:
Fran Jurmanović
2025-08-27 20:38:19 +02:00
parent 965e13a0bf
commit b269144ee7
120 changed files with 10393 additions and 8526 deletions

View File

@@ -1,54 +0,0 @@
import { authStore } from '$stores/authStore';
import { redirect, type RequestEvent } from '@sveltejs/kit';
import { redisSessionManager } from '$stores/redisSessionManager';
import { env } from '$env/dynamic/private';
const BASE_URL = env.API_BASE_URL;
async function fetchAPI(endpoint: string, method: string = 'GET', body?: object, hdr?: object) {
const headers = {
'Content-Type': 'application/json',
...(hdr ?? {})
};
const response = await fetch(`${BASE_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : null
});
if (!response.ok) {
if (response.status == 401) {
authStore.set({
username: undefined,
password: undefined,
token: undefined,
error: 'Wrong authorization key!'
});
redirect(303, '/login');
}
throw new Error(`API Error: ${response.statusText} - ${method} - ${endpoint}`);
}
if (response.headers.get('Content-Type') == 'application/json') return response.json();
return response.text();
}
export async function fetchAPIEvent(
event: RequestEvent,
endpoint: string,
method: string = 'GET',
body?: object
) {
if (!event.cookies) {
redirect(308, '/login');
}
const session = await redisSessionManager.getSession(event.cookies);
if (!session?.data?.token) {
redirect(308, '/login');
}
return fetchAPI(endpoint, method, body, { Authorization: `Bearer ${session.data.token}` });
}
export default fetchAPI;

View File

@@ -1,51 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import { env } from '$env/dynamic/private';
import { authStore } from '$stores/authStore';
import { redisSessionManager } from '$stores/redisSessionManager';
import type { RequestEvent } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
export const login = async (event: RequestEvent, username: string, password: string) => {
try {
const response = await fetch(`${env.API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: 'Invalid username or password.' }));
authStore.set({
token: undefined,
error: errorData.error || 'Invalid username or password.'
});
return false;
}
const { token } = await response.json();
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
return true;
} catch (err) {
authStore.set({ token: undefined, error: 'Login failed. Please try again.' });
return false;
}
};
export const logout = (event: RequestEvent) => {
return redisSessionManager.deleteCookie(event.cookies);
};
export const checkAuth = async (event: RequestEvent) => {
try {
await fetchAPIEvent(event, '/api');
return true;
} catch (err) {
return false;
}
};

View File

@@ -1,23 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import type { CarModel, CupCategory, DriverCategory, SessionType, Track } from '$models/lookups';
import type { RequestEvent } from '@sveltejs/kit';
export const getCarModels = async (event: RequestEvent): Promise<CarModel[]> => {
return fetchAPIEvent(event, '/lookup/car-models');
};
export const getCupCategories = async (event: RequestEvent): Promise<CupCategory[]> => {
return fetchAPIEvent(event, '/lookup/cup-categories');
};
export const getDriverCategories = async (event: RequestEvent): Promise<DriverCategory[]> => {
return fetchAPIEvent(event, '/lookup/driver-categories');
};
export const getSessionTypes = async (event: RequestEvent): Promise<SessionType[]> => {
return fetchAPIEvent(event, '/lookup/session-types');
};
export const getTracks = async (event: RequestEvent): Promise<Track[]> => {
return fetchAPIEvent(event, '/lookup/tracks');
};

View File

@@ -1,185 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import type { User } from '$models/user';
import type { RequestEvent } from '@sveltejs/kit';
export interface MembershipFilter {
username?: string;
role_name?: string;
role_id?: string;
page?: number;
page_size?: number;
sort_by?: string;
sort_desc?: boolean;
}
export interface PaginatedUsers {
users: User[];
pagination: {
page: number;
page_size: number;
total: number;
total_pages: number;
};
}
export interface CreateUserRequest {
username: string;
password: string;
role: string;
}
export interface UpdateUserRequest {
username?: string;
password?: string;
roleId?: string;
}
export interface Role {
id: string;
name: string;
}
export const membershipService = {
async getUsers(event: RequestEvent, filter?: MembershipFilter): Promise<User[]> {
const queryParams = new URLSearchParams();
if (filter) {
if (filter.username) queryParams.append('username', filter.username);
if (filter.role_name) queryParams.append('role_name', filter.role_name);
if (filter.role_id) queryParams.append('role_id', filter.role_id);
if (filter.page) queryParams.append('page', filter.page.toString());
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
if (filter.sort_desc !== undefined)
queryParams.append('sort_desc', filter.sort_desc.toString());
}
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return await fetchAPIEvent(event, endpoint);
},
async getUsersPaginated(event: RequestEvent, filter?: MembershipFilter): Promise<PaginatedUsers> {
const users = await this.getUsers(event, filter);
const page = filter?.page || 1;
const pageSize = filter?.page_size || 10;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedUsers = users.slice(startIndex, endIndex);
const totalPages = Math.ceil(users.length / pageSize);
return {
users: paginatedUsers,
pagination: {
page,
page_size: pageSize,
total: users.length,
total_pages: totalPages
}
};
},
async getUser(event: RequestEvent, userId: string): Promise<User> {
return await fetchAPIEvent(event, `/membership/${userId}`);
},
async createUser(event: RequestEvent, userData: CreateUserRequest): Promise<User> {
return await fetchAPIEvent(event, '/membership', 'POST', userData);
},
async updateUser(
event: RequestEvent,
userId: string,
userData: UpdateUserRequest
): Promise<User> {
return await fetchAPIEvent(event, `/membership/${userId}`, 'PUT', userData);
},
async deleteUser(event: RequestEvent, userId: string): Promise<void> {
await fetchAPIEvent(event, `/membership/${userId}`, 'DELETE');
},
async getRoles(event: RequestEvent): Promise<Role[]> {
return await fetchAPIEvent(event, '/membership/roles');
}
};
// Client-side service for browser usage (not currently used)
export const membershipClientService = {
async getUsers(filter?: MembershipFilter): Promise<User[]> {
const queryParams = new URLSearchParams();
if (filter) {
if (filter.username) queryParams.append('username', filter.username);
if (filter.role_name) queryParams.append('role_name', filter.role_name);
if (filter.role_id) queryParams.append('role_id', filter.role_id);
if (filter.page) queryParams.append('page', filter.page.toString());
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
if (filter.sort_desc !== undefined)
queryParams.append('sort_desc', filter.sort_desc.toString());
}
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to fetch users: ${response.statusText}`);
}
return response.json();
},
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await fetch('/membership', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.statusText}`);
}
return response.json();
},
async updateUser(userId: string, userData: UpdateUserRequest): Promise<User> {
const response = await fetch(`/membership/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`Failed to update user: ${response.statusText}`);
}
return response.json();
},
async deleteUser(userId: string): Promise<void> {
const response = await fetch(`/membership/${userId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete user: ${response.statusText}`);
}
},
async getRoles(): Promise<Role[]> {
const response = await fetch('/membership/roles');
if (!response.ok) {
throw new Error(`Failed to fetch roles: ${response.statusText}`);
}
return response.json();
}
};

View File

@@ -1,127 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import {
configFile,
type AssistRules,
type Config,
type ConfigFile,
type Configuration,
type Configurations,
type EventConfig,
type EventRules,
type ServerSettings,
type StateHistory,
type StateHistoryStats
} from '$models/config';
import type { Server } from '$models/server';
import type { RequestEvent } from '@sveltejs/kit';
export const getServers = async (event: RequestEvent): Promise<Server[]> => {
return fetchAPIEvent(event, '/server');
};
export const getServerById = async (event: RequestEvent, serverId: string): Promise<Server> => {
return fetchAPIEvent(event, `/server/${serverId}`);
};
export const getConfigFiles = async (
event: RequestEvent,
serverId: string
): Promise<Configurations> => {
return fetchAPIEvent(event, `/server/${serverId}/config`);
};
export const getConfigFile = async (
event: RequestEvent,
serverId: string,
file: ConfigFile
): Promise<Config> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${file}`);
};
export const getStateHistory = async (
event: RequestEvent,
serverId: string,
startDate: string,
endDate: string
): Promise<Array<StateHistory>> => {
return fetchAPIEvent(
event,
`/server/${serverId}/state-history?start_date=${startDate}&end_date=${endDate}`
);
};
export const getStateHistoryStats = async (
event: RequestEvent,
serverId: string,
startDate: string,
endDate: string
): Promise<StateHistoryStats> => {
return fetchAPIEvent(
event,
`/server/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`
);
};
export const getEventFile = async (event: RequestEvent, serverId: string): Promise<EventConfig> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.event}`);
};
export const getConfigurationFile = async (
event: RequestEvent,
serverId: string
): Promise<Configuration> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.configuration}`);
};
export const getAssistRulesFile = async (
event: RequestEvent,
serverId: string
): Promise<AssistRules> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.assistRules}`);
};
export const getEventRulesFile = async (
event: RequestEvent,
serverId: string
): Promise<EventRules> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.eventRules}`);
};
export const getSettingsFile = async (
event: RequestEvent,
serverId: string
): Promise<ServerSettings> => {
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.settings}`);
};
export const updateConfig = async (
event: RequestEvent,
serverId: string,
file: ConfigFile,
newConfig?: Config,
override: boolean | string = false,
restart: boolean | string = true
) => {
return fetchAPIEvent(
event,
`/server/${serverId}/config/${file}?override=${override}&restart=${restart}`,
'PUT',
newConfig
);
};
export const restartService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/restart`, 'POST');
};
export const startService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/start`, 'POST');
};
export const stopService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/stop`, 'POST');
};
export const getServiceStatus = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service`);
};

View File

@@ -1,4 +0,0 @@
@import 'tailwindcss';
@import './styles/button.css';
@import './styles/loader.css';
@import './styles/inputs.css';

15
src/app.d.ts vendored
View File

@@ -1,15 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: import('$models/user').User | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

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

View File

@@ -0,0 +1,78 @@
import { requireAuth } from '@/lib/auth/server';
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';
export default async function DashboardPage() {
const session = await requireAuth();
const servers = await getServers(session.token!);
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md">
<div className="mx-auto flex max-w-7xl 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>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold">Your Servers</h2>
<RefreshButton />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { requireAuth } from '@/lib/auth/server';
import { getServer } from '@/lib/api/server/servers';
import { getServerConfigurations } from '@/lib/api/server/configuration';
import { ServerConfigurationTabs } from '@/components/server/ServerConfigurationTabs';
import { ServerHeader } from '@/components/server/ServerHeader';
import { getServerStatistics } from '@/lib/api/server/statistics';
import { subDays, formatISO } from 'date-fns';
import { UTCDate } from '@date-fns/utc';
interface ServerPageProps {
params: { id: string };
}
export default async function ServerPage({ params }: ServerPageProps) {
const { id } = await params;
const session = await requireAuth();
const today = new UTCDate();
const endDate = formatISO(today);
const startDate = formatISO(subDays(today, 30));
const [server, configurations, statistics] = await Promise.all([
getServer(session.token!, id),
getServerConfigurations(session.token!, id),
getServerStatistics(session.token!, id, startDate, endDate)
]);
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<ServerHeader server={server} />
<div className="mt-8">
<ServerConfigurationTabs
serverId={id}
configurations={configurations}
statistics={statistics}
/>
</div>
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

11
src/app/globals.css Normal file
View File

@@ -0,0 +1,11 @@
@import "tailwindcss";
@layer components {
.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;
}
.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;
}
}

22
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import './globals.css';
import { QueryProvider } from '@/components/providers/QueryProvider';
export const metadata: Metadata = {
title: 'ACC Server Manager',
description: 'Assetto Corsa Competizione Server Management Interface'
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="bg-gray-900 text-white antialiased">
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}

51
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { loginAction } from '@/lib/actions/auth';
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4">
<div className="w-full max-w-md space-y-8 rounded-lg bg-gray-800 p-8 shadow-lg">
<div className="text-center">
<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>
<form action={loginAction} 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>
);
}

12
src/app/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
export default async function HomePage() {
const session = await getSession();
if (session.token && session.user) {
redirect('/dashboard');
} else {
redirect('/login');
}
}

View File

@@ -1,191 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { configFile, type AssistRules } from '$models/config';
const { config, id }: { config: AssistRules; id: string } = $props();
const editedConfig = $state({ ...config });
let formLoading = $state(false);
let restart = $state(true);
</script>
<form
method="POST"
action="?/update"
use:enhance={() => {
formLoading = true;
return async ({ update }) => {
await update({ invalidateAll: true, reset: false });
formLoading = false;
};
}}
class="max-w-3xl space-y-6"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="file" value={configFile.assistRules} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Stability Control Level Max -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Stability Control Level Max:
<input
bind:value={editedConfig.stabilityControlLevelMax}
disabled={formLoading}
name="stabilityControlLevelMax"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Disable Autosteer -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Autosteer:
<select
bind:value={editedConfig.disableAutosteer}
disabled={formLoading}
name="disableAutosteer"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Lights -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Lights:
<select
bind:value={editedConfig.disableAutoLights}
disabled={formLoading}
name="disableAutoLights"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Wiper -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Wiper:
<select
bind:value={editedConfig.disableAutoWiper}
disabled={formLoading}
name="disableAutoWiper"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Engine Start -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Engine Start:
<select
bind:value={editedConfig.disableAutoEngineStart}
disabled={formLoading}
name="disableAutoEngineStart"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Pit Limiter -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Pit Limiter:
<select
bind:value={editedConfig.disableAutoPitLimiter}
disabled={formLoading}
name="disableAutoPitLimiter"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Gear -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Gear:
<select
bind:value={editedConfig.disableAutoGear}
disabled={formLoading}
name="disableAutoGear"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Auto Clutch -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Auto Clutch:
<select
bind:value={editedConfig.disableAutoClutch}
disabled={formLoading}
name="disableAutoClutch"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Disable Ideal Line -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Disable Ideal Line:
<select
bind:value={editedConfig.disableIdealLine}
disabled={formLoading}
name="disableIdealLine"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
</div>
<div class="border-t border-gray-700 pt-4">
<label class="flex items-center">
<input
type="checkbox"
bind:checked={restart}
name="restart"
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={formLoading}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Save Changes
</button>
</div>
</form>
<style></style>

View File

@@ -1,115 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { configFile, type Configuration } from '$models/config';
const { config, id }: { config: Configuration; id: string } = $props();
const editedConfig = $state({ ...config });
let formLoading = $state(false);
let restart = $state(true);
</script>
<form
method="POST"
action="?/update"
use:enhance={() => {
formLoading = true;
return async ({ update }) => {
await update({ invalidateAll: true, reset: false });
formLoading = false;
};
}}
class="max-w-3xl space-y-6"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="file" value={configFile.configuration} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
UDP Port:
<input
disabled={formLoading}
name="udpPort"
type="number"
class="form form-input"
bind:value={editedConfig.udpPort}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
TCP Port:
<input
disabled={formLoading}
name="tcpPort"
type="number"
class="form form-input"
bind:value={editedConfig.tcpPort}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Max Connections:
<input
disabled={formLoading}
name="maxConnections"
type="number"
class="form form-input"
bind:value={editedConfig.maxConnections}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Lan Discovery:
<select
bind:value={editedConfig.lanDiscovery}
disabled={formLoading}
name="lanDiscovery"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Register To Lobby:
<select
bind:value={editedConfig.registerToLobby}
disabled={formLoading}
name="registerToLobby"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<input type="hidden" name="configVersion" value={1} />
</div>
<div class="border-t border-gray-700 pt-4">
<label class="flex items-center">
<input
type="checkbox"
name="restart"
bind:checked={restart}
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={formLoading}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Save Changes
</button>
</div>
</form>
<style></style>

View File

@@ -1,328 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { configFile, type EventConfig } from '$models/config';
import type { Track } from '$models/lookups';
const { config, tracks, id }: { config: EventConfig; tracks: Track[]; id: string } = $props();
const editedConfig = $state({ ...config });
if (!editedConfig.sessions) editedConfig.sessions = [];
let formLoading = $state(false);
let restart = $state(true);
</script>
<form
method="POST"
action="?/update"
use:enhance={() => {
formLoading = true;
return async ({ update }) => {
await update({ invalidateAll: true, reset: false });
formLoading = false;
};
}}
class="max-w-3xl space-y-6"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="file" value={configFile.event} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Track:
<select
bind:value={editedConfig.track}
disabled={formLoading}
name="track"
class="form form-select"
>
{#each tracks as track}
<option value={track.track}>{track.track}</option>
{/each}
</select>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Pre-Race waiting time seconds:
<input
disabled={formLoading}
name="preRaceWaitingTimeSeconds"
type="number"
class="form form-input"
bind:value={editedConfig.preRaceWaitingTimeSeconds}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Session over time seconds:
<input
disabled={formLoading}
name="sessionOverTimeSeconds"
type="number"
class="form form-input"
bind:value={editedConfig.sessionOverTimeSeconds}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Ambient temp:
<input
disabled={formLoading}
name="ambientTemp"
type="number"
class="form form-input"
bind:value={editedConfig.ambientTemp}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Cloud level:
<input
disabled={formLoading}
name="cloudLevel"
type="number"
class="form form-input"
bind:value={editedConfig.cloudLevel}
step=".01"
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Rain:
<input
disabled={formLoading}
name="rain"
type="number"
class="form form-input"
bind:value={editedConfig.rain}
step=".01"
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Weather randomness:
<input
disabled={formLoading}
name="weatherRandomness"
type="number"
class="form form-input"
bind:value={editedConfig.weatherRandomness}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Post-Qualy seconds:
<input
disabled={formLoading}
name="postQualySeconds"
type="number"
class="form form-input"
bind:value={editedConfig.postQualySeconds}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Post-Race seconds:
<input
disabled={formLoading}
name="postRaceSeconds"
type="number"
class="form form-input"
bind:value={editedConfig.postRaceSeconds}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Simracer weather conditions:
<input
disabled={formLoading}
name="simracerWeatherConditions"
type="number"
class="form form-input"
bind:value={editedConfig.simracerWeatherConditions}
/>
</label>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is fixed condition qualification:
<select
bind:value={editedConfig.isFixedConditionQualification}
disabled={formLoading}
name="isFixedConditionQualification"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<div />
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Sessions:
<div class="mt-2 space-y-4">
{#each editedConfig.sessions as session, index}
<div class="mb-4 rounded-lg border border-gray-700 bg-gray-800 p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Hour of Day -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300"
>Hour of Day:
<input
bind:value={session.hourOfDay}
name={`sessions[${index}].hourOfDay`}
disabled={formLoading}
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Day of Weekend -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Day of Weekend:
<input
bind:value={session.dayOfWeekend}
name={`sessions[${index}].dayOfWeekend`}
disabled={formLoading}
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Time Multiplier -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Time Multiplier:
<input
bind:value={session.timeMultiplier}
name={`sessions[${index}].timeMultiplier`}
disabled={formLoading}
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Session Type -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Session Type:
<select
bind:value={session.sessionType}
name={`sessions[${index}].sessionType`}
disabled={formLoading}
class="form form-select"
>
<option value="P">Practice</option>
<option value="Q">Qualifying</option>
<option value="R">Race</option>
</select>
</label>
</div>
<!-- Session Duration Minutes -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Session Duration (Minutes):
<input
bind:value={session.sessionDurationMinutes}
name={`sessions[${index}].sessionDurationMinutes`}
disabled={formLoading}
type="number"
class="form form-input"
/>
</label>
</div>
</div>
<button
type="button"
onclick={() => {
editedConfig.sessions = editedConfig.sessions.filter((_, i) => i !== index);
}}
class="mt-4 flex items-center rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Remove Session
</button>
</div>
{/each}
<button
type="button"
onclick={() => {
editedConfig.sessions = [
...editedConfig.sessions,
{
hourOfDay: 14,
dayOfWeekend: 1,
timeMultiplier: 1,
sessionType: 'Practice',
sessionDurationMinutes: 60
}
];
}}
class="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Session
</button>
</div>
</label>
</div>
</div>
<div class="border-t border-gray-700 pt-4">
<label class="flex items-center">
<input
type="checkbox"
name="restart"
bind:checked={restart}
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={formLoading}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Save Changes
</button>
</div>
</form>
<style></style>

View File

@@ -1,213 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { configFile, type EventRules } from '$models/config';
const { config, id }: { config: EventRules; id: string } = $props();
const editedConfig = $state({ ...config });
let formLoading = $state(false);
let restart = $state(true);
</script>
<form
method="POST"
action="?/update"
use:enhance={() => {
formLoading = true;
return async ({ update }) => {
await update({ invalidateAll: true, reset: false });
formLoading = false;
};
}}
class="max-w-3xl space-y-6"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="file" value={configFile.eventRules} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Qualify Standing Type -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Qualify Standing Type:
<input
bind:value={editedConfig.qualifyStandingType}
disabled={formLoading}
name="qualifyStandingType"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Pit Window Length Sec -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Pit Window Length (Sec):
<input
bind:value={editedConfig.pitWindowLengthSec}
disabled={formLoading}
name="pitWindowLengthSec"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Driver Stint Time Sec -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Driver Stint Time (Sec):
<input
bind:value={editedConfig.driverStintTimeSec}
disabled={formLoading}
name="driverStintTimeSec"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Mandatory Pitstop Count -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Mandatory Pitstop Count:
<input
bind:value={editedConfig.mandatoryPitstopCount}
disabled={formLoading}
name="mandatoryPitstopCount"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Max Total Driving Time -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Max Total Driving Time:
<input
bind:value={editedConfig.maxTotalDrivingTime}
disabled={formLoading}
name="maxTotalDrivingTime"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Is Refuelling Allowed In Race -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Refuelling Allowed In Race:
<select
bind:value={editedConfig.isRefuellingAllowedInRace}
disabled={formLoading}
name="isRefuellingAllowedInRace"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Is Refuelling Time Fixed -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Refuelling Time Fixed:
<select
bind:value={editedConfig.isRefuellingTimeFixed}
disabled={formLoading}
name="isRefuellingTimeFixed"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Is Mandatory Pitstop Refuelling Required -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Mandatory Pitstop Refuelling Required:
<select
bind:value={editedConfig.isMandatoryPitstopRefuellingRequired}
disabled={formLoading}
name="isMandatoryPitstopRefuellingRequired"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Is Mandatory Pitstop Tyre Change Required -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Mandatory Pitstop Tyre Change Required:
<select
bind:value={editedConfig.isMandatoryPitstopTyreChangeRequired}
disabled={formLoading}
name="isMandatoryPitstopTyreChangeRequired"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Is Mandatory Pitstop Swap Driver Required -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Mandatory Pitstop Swap Driver Required:
<select
bind:value={editedConfig.isMandatoryPitstopSwapDriverRequired}
disabled={formLoading}
name="isMandatoryPitstopSwapDriverRequired"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Tyre Set Count -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Tyre Set Count:
<input
bind:value={editedConfig.tyreSetCount}
disabled={formLoading}
name="tyreSetCount"
type="number"
class="form form-input"
/>
</label>
</div>
</div>
<div class="border-t border-gray-700 pt-4">
<label class="flex items-center">
<input
type="checkbox"
name="restart"
bind:checked={restart}
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={formLoading}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Save Changes
</button>
</div>
</form>
<style></style>

View File

@@ -1,306 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { configFile, type ServerSettings } from '$models/config';
const { config, id }: { config: ServerSettings; id: string } = $props();
const editedConfig = $state({ ...config });
let formLoading = $state(false);
let restart = $state(true);
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
</script>
<form
method="POST"
action="?/update"
use:enhance={() => {
formLoading = true;
return async ({ update }) => {
await update({ invalidateAll: true, reset: false });
formLoading = false;
};
}}
class="max-w-3xl space-y-6"
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="file" value={configFile.settings} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Server Name -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Server Name:
<input
bind:value={editedConfig.serverName}
disabled={formLoading}
name="serverName"
type="text"
class="form form-input"
/>
</label>
</div>
<!-- Admin Password -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Admin Password:
<input
bind:value={editedConfig.adminPassword}
disabled={formLoading}
name="adminPassword"
type="password"
class="form form-input"
/>
</label>
</div>
<!-- Car Group -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Car Group:
<select
bind:value={editedConfig.carGroup}
disabled={formLoading}
name="carGroup"
class="form form-select"
>
{#each carGroups as group}
<option value={group}>{group}</option>
{/each}
</select>
</label>
</div>
<!-- Track Medals Requirement -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Track Medals Requirement:
<input
bind:value={editedConfig.trackMedalsRequirement}
disabled={formLoading}
name="trackMedalsRequirement"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Safety Rating Requirement -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Safety Rating Requirement:
<input
bind:value={editedConfig.safetyRatingRequirement}
disabled={formLoading}
name="safetyRatingRequirement"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Racecraft Rating Requirement -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Racecraft Rating Requirement:
<input
bind:value={editedConfig.racecraftRatingRequirement}
disabled={formLoading}
name="racecraftRatingRequirement"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Password -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Password:
<input
bind:value={editedConfig.password}
disabled={formLoading}
name="password"
type="password"
class="form form-input"
/>
</label>
</div>
<!-- Spectator Password -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Spectator Password:
<input
bind:value={editedConfig.spectatorPassword}
disabled={formLoading}
name="spectatorPassword"
type="password"
class="form form-input"
/>
</label>
</div>
<!-- Max Car Slots -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Max Car Slots:
<input
bind:value={editedConfig.maxCarSlots}
disabled={formLoading}
name="maxCarSlots"
type="number"
class="form form-input"
/>
</label>
</div>
<!-- Dump Leaderboards -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Dump Leaderboards:
<select
bind:value={editedConfig.dumpLeaderboards}
disabled={formLoading}
name="dumpLeaderboards"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Is Race Locked -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Is Race Locked:
<select
bind:value={editedConfig.isRaceLocked}
disabled={formLoading}
name="isRaceLocked"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Randomize Track When Empty -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Randomize Track When Empty:
<select
bind:value={editedConfig.randomizeTrackWhenEmpty}
disabled={formLoading}
name="randomizeTrackWhenEmpty"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Central Entry List Path -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Central Entry List Path:
<input
bind:value={editedConfig.centralEntryListPath}
disabled={formLoading}
name="centralEntryListPath"
type="text"
class="form form-input"
/>
</label>
</div>
<!-- Allow Auto DQ -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Allow Auto DQ:
<select
bind:value={editedConfig.allowAutoDQ}
disabled={formLoading}
name="allowAutoDQ"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Short Formation Lap -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Short Formation Lap:
<select
bind:value={editedConfig.shortFormationLap}
disabled={formLoading}
name="shortFormationLap"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
<!-- Formation Lap Type -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Formation Lap Type:
<select
bind:value={editedConfig.formationLapType}
disabled={formLoading}
name="formationLapType"
class="form form-select"
>
<option value={0}>Old Limiter Lap</option>
<option value={1}>Free (replaces /manual start), only usable for private servers</option>
<option value={3}>Default formation lap with position control and UI</option>
</select>
</label>
</div>
<!-- Ignore Premature Disconnects -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">
Ignore Premature Disconnects:
<select
bind:value={editedConfig.ignorePrematureDisconnects}
disabled={formLoading}
name="ignorePrematureDisconnects"
class="form form-select"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</label>
</div>
</div>
<div class="border-t border-gray-700 pt-4">
<label class="flex items-center">
<input
type="checkbox"
bind:checked={restart}
name="restart"
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={formLoading}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Save Changes
</button>
</div>
</form>
<style></style>

View File

@@ -1,95 +0,0 @@
<script lang="ts">
export let currentPage: number = 1;
export let totalPages: number = 1;
export let onPageChange: (page: number) => void = () => {};
$: pages = generatePageNumbers(currentPage, totalPages);
function generatePageNumbers(current: number, total: number): (number | string)[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | string)[] = [];
if (current <= 4) {
// Show first 5 pages, then ellipsis, then last page
pages.push(...[1, 2, 3, 4, 5]);
if (total > 6) pages.push('...');
pages.push(total);
} else if (current >= total - 3) {
// Show first page, ellipsis, then last 5 pages
pages.push(1);
if (total > 6) pages.push('...');
pages.push(...[total - 4, total - 3, total - 2, total - 1, total]);
} else {
// Show first page, ellipsis, current and neighbors, ellipsis, last page
pages.push(1, '...', current - 1, current, current + 1, '...', total);
}
return pages;
}
function handlePageClick(page: number | string) {
if (typeof page === 'number' && page !== currentPage) {
onPageChange(page);
}
}
function handlePrevious() {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
}
function handleNext() {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
}
</script>
<div class="flex items-center justify-center space-x-1">
<!-- Previous button -->
<button
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === 1
? 'cursor-not-allowed bg-gray-700 text-gray-500'
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
disabled={currentPage === 1}
onclick={handlePrevious}
>
Previous
</button>
<!-- Page numbers -->
{#each pages as page}
{#if page === '...'}
<span class="px-3 py-2 text-sm text-gray-500">...</span>
{:else}
<button
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {page === currentPage
? 'bg-blue-600 text-white'
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
onclick={() => handlePageClick(page)}
>
{page}
</button>
{/if}
{/each}
<!-- Next button -->
<button
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === totalPages
? 'cursor-not-allowed bg-gray-700 text-gray-500'
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
disabled={currentPage === totalPages}
onclick={handleNext}
>
Next
</button>
</div>
<!-- Page info -->
<div class="mt-2 text-center text-sm text-gray-400">
Page {currentPage} of {totalPages}
</div>

View File

@@ -1,79 +0,0 @@
<script lang="ts">
import type { Server } from '$models/server';
import { getStatusColor, ServiceStatus, serviceStatusToString } from '$lib/types/serviceStatus';
let { server }: { server: Server } = $props();
</script>
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
<a href={`dashboard/server/${server.id}`}>
<div class="p-4">
<div class="flex items-start justify-between">
<div>
<h3 class="text-lg font-medium">{server.name}</h3>
<div class="mt-1 flex items-center">
<span class={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
></span>
<span class="text-sm capitalize">{serviceStatusToString(server.status)}</span>
</div>
</div>
<button class="text-gray-400 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="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>
</button>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
<div>
<span class="text-gray-500">Track:</span>
{server.state?.track}
</div>
<div>
<span class="text-gray-500">Players:</span>
{server.state?.playerCount}
</div>
</div>
</div>
</a>
<form method="POST" action="?/start">
<div class="flex justify-between bg-gray-900 px-4 py-3">
<input type="hidden" name="id" value={server.id} />
<button
type="submit"
disabled={server.status === ServiceStatus.Running}
onclick={(e) => e.stopPropagation()}
class="rounded-md bg-green-600 px-3 py-1 text-xs font-medium hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Start
</button>
<button
disabled={server.status === ServiceStatus.Stopped}
onclick={(e) => e.stopPropagation()}
class="rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
formaction="?/restart"
>
Restart
</button>
<button
disabled={server.status === ServiceStatus.Stopped}
onclick={(e) => e.stopPropagation()}
class="rounded-md bg-red-600 px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
formaction="?/stop"
>
Stop
</button>
</div>
</form>
</div>
<style>
</style>

View File

@@ -1,346 +0,0 @@
<script lang="ts">
import Chart from 'chart.js/auto';
import { onMount, onDestroy } from 'svelte';
import { compareAsc, isSameDay } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import 'chartjs-adapter-date-fns';
import type { StateHistoryStats } from '$models/config';
import { flatMap } from 'lodash-es';
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
interface Props {
// Props
statistics: StateHistoryStats;
}
let { statistics }: Props = $props();
// Chart instances
let playerCountChart: Chart | null = null;
let sessionTypeChart: Chart | null = null;
let dailyActivityChart: Chart | null = null;
// Chart canvas elements
let playerCountCanvas: HTMLCanvasElement | undefined = $state();
let sessionTypeCanvas: HTMLCanvasElement | undefined = $state();
let dailyActivityCanvas: HTMLCanvasElement | undefined = $state();
// Initialize date range (last 30 days by default)
onMount(() => {
createCharts();
});
onDestroy(() => {
// Cleanup charts
if (playerCountChart) playerCountChart.destroy();
if (sessionTypeChart) sessionTypeChart.destroy();
if (dailyActivityChart) dailyActivityChart.destroy();
});
function createCharts() {
if (!statistics || !playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
playerCountChart = new Chart(playerCountCanvas, {
type: 'line',
data: {
labels: statistics.playerCountOverTime.map(({ timestamp }) =>
formatDate(timestamp, 'MMM dd kk:mm')
),
datasets: [
{
label: 'Player Count',
data: statistics.playerCountOverTime.map(({ count }) => count),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#ffffff'
}
}
}
}
});
sessionTypeChart = new Chart(sessionTypeCanvas, {
type: 'doughnut',
data: {
labels: statistics.sessionTypes.map(({ name }) => name),
datasets: [
{
data: statistics.sessionTypes.map(({ count }) => count),
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#ffffff',
padding: 20
}
}
}
}
});
dailyActivityChart = new Chart(dailyActivityCanvas, {
type: 'bar',
data: {
labels: statistics.dailyActivity.map(({ date }) => date),
datasets: [
{
label: 'Sessions',
data: statistics.dailyActivity.map(({ sessionsCount }) => sessionsCount),
backgroundColor: '#10b981',
borderColor: '#059669',
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#ffffff'
}
}
},
scales: {
x: {
ticks: {
color: '#9ca3af'
},
grid: {
color: '#374151'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#9ca3af'
},
grid: {
color: '#374151'
}
}
}
}
});
}
function formatDate(dateString: string, formatString: string) {
return formatInTimeZone(dateString, localTimeZone, formatString, {
timeZone: 'utc'
});
}
function formatDuration(minutes: number) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
</script>
<!-- Summary Cards -->
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
<div class="rounded-lg bg-gray-800 p-6">
<div class="flex items-center">
<div class="rounded-lg bg-green-600 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-400">Average Players</p>
<p class="text-2xl font-bold text-white">{Math.round(statistics.averagePlayers)}</p>
</div>
</div>
</div>
<div class="rounded-lg bg-gray-800 p-6">
<div class="flex items-center">
<div class="rounded-lg bg-yellow-600 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-400">Peak Players</p>
<p class="text-2xl font-bold text-white">{statistics.peakPlayers}</p>
</div>
</div>
</div>
<div class="rounded-lg bg-gray-800 p-6">
<div class="flex items-center">
<div class="rounded-lg bg-blue-600 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
<p class="text-2xl font-bold text-white">{statistics.totalSessions}</p>
</div>
</div>
</div>
<div class="rounded-lg bg-gray-800 p-6">
<div class="flex items-center">
<div class="rounded-lg bg-purple-600 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
<p class="text-2xl font-bold text-white">{formatDuration(statistics.totalPlaytime)}</p>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Player Count Over Time -->
<div class="rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold">Player Count Over Time</h3>
<div class="h-64">
<canvas bind:this={playerCountCanvas}></canvas>
</div>
</div>
<!-- Session Types Pie Chart -->
<div class="rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold">Session Types</h3>
<div class="h-64">
<canvas bind:this={sessionTypeCanvas}></canvas>
</div>
</div>
</div>
<!-- Daily Activity Bar Chart -->
<div class="mb-8 rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold">Daily Activity</h3>
<div class="h-64">
<canvas bind:this={dailyActivityCanvas}></canvas>
</div>
</div>
<!-- Session History Table -->
<div class="rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold">Recent Sessions</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
>Date</th
>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
>Type</th
>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
>Track</th
>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
>Duration</th
>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
>Players</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-700 bg-gray-800">
{#each statistics.recentSessions as session}
<tr>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{formatDate(session.date, 'MMM dd kk:mm')}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
session.type === 'Race'
? 'bg-red-100 text-red-800'
: session.type === 'Qualifying'
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}
>
{session.type}
</span>
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{session.track}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{formatDuration(session.duration)}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{session.players}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
let { message = '', type = 'success', duration = 5000 } = $props();
let visible = $state(true);
let timer: number;
onMount(() => {
timer = window.setTimeout(() => {
visible = false;
}, duration);
});
onDestroy(() => {
clearTimeout(timer);
});
const baseClasses =
'fixed bottom-5 right-5 p-4 rounded-lg shadow-lg text-white transition-all duration-500 ease-in-out transform';
const typeClasses = {
success: 'bg-green-600',
error: 'bg-red-600'
};
const visibilityClasses = visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5';
</script>
{#if visible}
<div class="{baseClasses} {typeClasses[type]} {visibilityClasses}" role="alert">
<p>{message}</p>
</div>
{/if}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import type { AssistRules } from '@/lib/types/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps {
serverId: string;
config: AssistRules;
}
export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) {
const [formData, setFormData] = useState<AssistRules>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateAssistRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update assist rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof AssistRules, value: string | number) => {
setFormData((prev) => ({
...prev,
[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 (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
{type === 'number' ? (
<input
type="number"
disabled={isSubmitting}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
min="0"
max="5"
/>
) : (
<select
disabled={isSubmitting}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
>
<option value={0}>Allowed</option>
<option value={1}>Disabled</option>
</select>
)}
</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,132 @@
'use client';
import { useState } from 'react';
import type { Configuration } from '@/lib/types/config';
import { updateConfigurationAction } from '@/lib/actions/configuration';
interface ConfigurationEditorProps {
serverId: string;
config: Configuration;
}
export function ConfigurationEditor({ serverId, config }: 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) => {
setFormData((prev) => ({
...prev,
[key]: typeof value === 'string' ? parseInt(value) : value
}));
};
return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">UDP Port</label>
<input
type="number"
disabled={isSubmitting}
value={formData.udpPort}
onChange={(e) => handleInputChange('udpPort', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">TCP Port</label>
<input
type="number"
disabled={isSubmitting}
value={formData.tcpPort}
onChange={(e) => handleInputChange('tcpPort', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Max Connections</label>
<input
type="number"
disabled={isSubmitting}
value={formData.maxConnections}
onChange={(e) => handleInputChange('maxConnections', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">LAN Discovery</label>
<select
disabled={isSubmitting}
value={formData.lanDiscovery}
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Register To Lobby</label>
<select
disabled={isSubmitting}
value={formData.registerToLobby}
onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</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,426 @@
'use client';
import { useState } from 'react';
import type { EventConfig, Session } from '@/lib/types/config';
import { updateEventConfigAction } from '@/lib/actions/configuration';
interface EventConfigEditorProps {
serverId: string;
config: EventConfig;
}
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) {
const [formData, setFormData] = useState<EventConfig>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (key === 'sessions') {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value.toString());
}
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventConfigAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event config:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return;
setFormData((prev) => ({
...prev,
[key]:
typeof formData[key] === 'number'
? typeof value === 'string'
? parseFloat(value) || 0
: value
: value
}));
};
const handleSessionChange = (index: number, field: keyof Session, value: string | number) => {
const newSessions = [...formData.sessions];
newSessions[index] = {
...newSessions[index],
[field]:
field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value
};
setFormData((prev) => ({
...prev,
sessions: newSessions
}));
};
const addSession = () => {
const newSession: Session = {
hourOfDay: 12,
dayOfWeekend: 1,
timeMultiplier: 1,
sessionType: 'P',
sessionDurationMinutes: 20
};
setFormData((prev) => ({
...prev,
sessions: [...prev.sessions, newSession]
}));
};
const removeSession = (index: number) => {
setFormData((prev) => ({
...prev,
sessions: prev.sessions.filter((_, i) => i !== index)
}));
};
const sessionTypes = [
{ value: 'P', label: 'Practice' },
{ value: 'Q', label: 'Qualifying' },
{ value: 'R', label: 'Race' }
];
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Basic Event Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Track</label>
<select
disabled={isSubmitting}
value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)}
className="form-input w-full"
>
<option value="barcelona">Barcelona</option>
<option value="brands_hatch">Brands Hatch</option>
<option value="hungaroring">Hungaroring</option>
<option value="imola">Imola</option>
<option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option>
<option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option>
<option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option>
<option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option>
<option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Pre-Race Waiting Time (seconds)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Session Over Time (seconds)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Post Qualify Seconds
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Post Race Seconds
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.postRaceSeconds}
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Weather Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Ambient Temperature (°C)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full"
min="0"
max="50"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Cloud Level (0.0-1.0)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.cloudLevel}
onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
className="form-input w-full"
min="0"
max="1"
step="0.01"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Rain (0.0-1.0)</label>
<input
type="number"
disabled={isSubmitting}
value={formData.rain}
onChange={(e) => handleInputChange('rain', e.target.value)}
className="form-input w-full"
min="0"
max="1"
step="0.01"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Weather Randomness
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full"
min="0"
max="7"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Simracer Weather Conditions
</label>
<select
disabled={isSubmitting}
value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Fixed Condition Qualification
</label>
<select
disabled={isSubmitting}
value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
</div>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between border-b border-gray-700 pb-2">
<h3 className="text-lg font-medium text-white">Sessions</h3>
<button
type="button"
onClick={addSession}
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Add Session
</button>
</div>
<div className="space-y-4">
{formData.sessions.map((session, index) => (
<div key={index} className="rounded-lg bg-gray-800 p-4">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-medium text-white">Session {index + 1}</h4>
<button
type="button"
onClick={() => removeSession(index)}
disabled={isSubmitting}
className="rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Session Type
</label>
<select
disabled={isSubmitting}
value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-input w-full text-sm"
>
{sessionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Hour of Day
</label>
<input
type="number"
disabled={isSubmitting}
value={session.hourOfDay}
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
className="form-input w-full text-sm"
min="0"
max="23"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Day of Weekend
</label>
<input
type="number"
disabled={isSubmitting}
value={session.dayOfWeekend}
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
className="form-input w-full text-sm"
min="1"
max="3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Time Multiplier
</label>
<input
type="number"
disabled={isSubmitting}
value={session.timeMultiplier}
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
className="form-input w-full text-sm"
min="1"
max="24"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Duration (minutes)
</label>
<input
type="number"
disabled={isSubmitting}
value={session.sessionDurationMinutes}
onChange={(e) =>
handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
}
className="form-input w-full text-sm"
min="1"
/>
</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,172 @@
'use client';
import { useState } from 'react';
import type { EventRules } from '@/lib/types/config';
import { updateEventRulesAction } from '@/lib/actions/configuration';
interface EventRulesEditorProps {
serverId: string;
config: EventRules;
}
export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
const [formData, setFormData] = useState<EventRules>(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 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 (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{numberFields.map(({ key, label, min, max }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<input
type="number"
disabled={isSubmitting}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full"
min={min}
max={max}
/>
</div>
))}
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Pitstop & Refuelling Rules
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{booleanFields.map(({ key, label }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<select
disabled={isSubmitting}
value={formData[key] ? 'true' : 'false'}
onChange={(e) => handleInputChange(key, e.target.value === 'true')}
className="form-input w-full"
>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</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,247 @@
'use client';
import { useState } from 'react';
import type { ServerSettings } from '@/lib/types/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps {
serverId: string;
config: ServerSettings;
}
export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) {
const [formData, setFormData] = useState<ServerSettings>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateServerSettingsAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update server settings:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
setFormData((prev) => ({
...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 (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Basic Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{textFields.map(({ key, label, type }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<input
type={type}
disabled={isSubmitting}
value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
/>
</div>
))}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Car Group</label>
<select
disabled={isSubmitting}
value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-input w-full"
>
{carGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Requirements & Limits
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{numberFields.map(({ key, label, min, max }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<input
type="number"
disabled={isSubmitting}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full"
min={min}
max={max}
/>
</div>
))}
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Race Options
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{selectFields.map(({ key, label }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<select
disabled={isSubmitting}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
))}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Formation Lap Type
</label>
<select
disabled={isSubmitting}
value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-input w-full"
>
<option value={0}>Old Limiter Lap</option>
<option value={1}>
Free (replaces /manual start), only usable for private servers
</option>
<option value={3}>Default formation lap with position control and UI</option>
</select>
</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,128 @@
'use client';
import { useState } from 'react';
import type { Role } from '@/lib/types';
import { createUserAction } from '@/lib/actions/membership';
interface CreateUserModalProps {
roles: Role[];
onClose: () => void;
}
export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
const [formData, setFormData] = useState({
username: '',
password: '',
role: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formDataObj = new FormData();
formDataObj.append('username', formData.username);
formDataObj.append('password', formData.password);
formDataObj.append('role', formData.role);
try {
const result = await createUserAction(formDataObj);
if (result.success) {
onClose();
window.location.reload(); // Refresh to show new user
} else {
setError(result.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">Create New User</h3>
{error && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="create-username" className="block text-sm font-medium text-gray-300">
Username
</label>
<input
id="create-username"
type="text"
value={formData.username}
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
required
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"
/>
</div>
<div className="mb-4">
<label htmlFor="create-password" className="block text-sm font-medium text-gray-300">
Password
</label>
<input
id="create-password"
type="password"
value={formData.password}
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
required
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"
/>
</div>
<div className="mb-6">
<label htmlFor="create-role" className="block text-sm font-medium text-gray-300">
Role
</label>
<select
id="create-role"
value={formData.role}
onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))}
required
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"
>
<option value="">Select a role...</option>
{roles.map((role) => (
<option key={role.id} value={role.name}>
{role.name}
</option>
))}
</select>
</div>
<div className="flex justify-end space-x-2">
<button
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
</button>
<button
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'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import type { User } from '@/lib/types';
import { deleteUserAction } from '@/lib/actions/membership';
interface DeleteUserModalProps {
user: User;
onClose: () => void;
}
export function DeleteUserModal({ user, onClose }: DeleteUserModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formDataObj = new FormData();
formDataObj.append('id', user.id);
try {
const result = await deleteUserAction(formDataObj);
if (result.success) {
onClose();
window.location.reload(); // Refresh to remove deleted user
} else {
setError(result.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">Delete User</h3>
{error && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>
)}
<p className="mb-6 text-gray-300">
Are you sure you want to delete the user &quot;{user.username}&quot;? This action cannot
be undone.
</p>
<form onSubmit={handleSubmit}>
<div className="flex justify-end space-x-2">
<button
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
</button>
<button
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'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { Role, User } from '@/lib/types';
import { hasPermission } from '@/lib/types/user';
import { CreateUserModal } from './CreateUserModal';
import { DeleteUserModal } from './DeleteUserModal';
interface UserManagementTableProps {
initialData: User[];
roles: Role[];
currentUser: User;
}
export function UserManagementTable({ initialData, roles, currentUser }: UserManagementTableProps) {
const router = useRouter();
const [usernameFilter, setUsernameFilter] = useState('');
const [roleNameFilter, setRoleNameFilter] = useState('');
const [sortBy, setSortBy] = useState('username');
const [sortDesc, setSortDesc] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const applyFilters = () => {
const params = new URLSearchParams();
if (usernameFilter) params.set('username', usernameFilter);
if (roleNameFilter) params.set('role_name', roleNameFilter);
params.set('sort_by', sortBy);
params.set('sort_desc', sortDesc.toString());
params.set('page', '1');
router.push(`/dashboard/membership?${params.toString()}`);
};
const resetFilters = () => {
setUsernameFilter('');
setRoleNameFilter('');
setSortBy('username');
setSortDesc(false);
router.push('/dashboard/membership');
};
const handleSort = (field: string) => {
if (sortBy === field) {
setSortDesc(!sortDesc);
} else {
setSortBy(field);
setSortDesc(false);
}
applyFilters();
};
const getSortIcon = (field: string) => {
if (sortBy !== field) return '↕️';
return sortDesc ? '↓' : '↑';
};
const openCreateModal = () => {
setShowCreateModal(true);
};
const closeCreateModal = () => {
setShowCreateModal(false);
};
const openDeleteModal = (user: User) => {
setSelectedUser(user);
setShowDeleteModal(true);
};
const closeDeleteModal = () => {
setSelectedUser(null);
setShowDeleteModal(false);
};
return (
<>
<header className="bg-gray-800 shadow-md">
<div className="mx-auto flex max-w-7xl 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-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>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Filters */}
<div className="mb-6 rounded-lg border border-gray-700 bg-gray-800 p-4">
<h2 className="mb-3 text-lg font-semibold">Filters</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300">
Username
</label>
<input
id="username"
type="text"
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
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 className="mt-4 flex space-x-2">
<button
onClick={applyFilters}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-700"
>
Apply Filters
</button>
<button
onClick={resetFilters}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700"
>
Reset
</button>
</div>
</div>
{/* Results Summary */}
<div className="mb-4 text-sm text-gray-400">Showing {initialData.length} users</div>
{/* Users Table */}
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-900">
<tr>
<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('username')}
>
<span>Username</span>
<span>{getSortIcon('username')}</span>
</button>
</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>
</table>
</div>
</main>
{/* Modals */}
{showCreateModal && <CreateUserModal roles={roles} onClose={closeCreateModal} />}
{showDeleteModal && selectedUser && (
<DeleteUserModal user={selectedUser} onClose={closeDeleteModal} />
)}
</>
);
}

View File

@@ -0,0 +1,13 @@
'use client';
import { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/api/client/query-client';
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,84 @@
import Link from 'next/link';
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/types';
import { startServerAction, stopServerAction, restartServerAction } from '@/lib/actions/servers';
interface ServerCardProps {
server: Server;
}
export function ServerCard({ server }: ServerCardProps) {
return (
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
<Link href={`/dashboard/server/${server.id}`} className="block">
<div className="p-6">
<div className="flex items-start justify-between">
<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="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 className="mt-4 grid grid-cols-2 gap-4 text-sm text-gray-300">
<div>
<span className="text-gray-500">Track:</span>
<span className="ml-2">{server.state?.track || 'N/A'}</span>
</div>
<div>
<span className="text-gray-500">Players:</span>
<span className="ml-2">{server.state?.playerCount || 0}</span>
</div>
</div>
</div>
</Link>
<div className="flex justify-between gap-2 bg-gray-900 px-4 py-3">
<form action={startServerAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Running}
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>
</form>
<form action={restartServerAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
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>
</form>
<form action={stopServerAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
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>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { Configurations, ServerTab } from '@/lib/types/config';
import { ConfigurationEditor } from '@/components/configuration/ConfigurationEditor';
import { AssistRulesEditor } from '@/components/configuration/AssistRulesEditor';
import { EventConfigEditor } from '@/components/configuration/EventConfigEditor';
import { EventRulesEditor } from '@/components/configuration/EventRulesEditor';
import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsEditor';
import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard';
import { useState } from 'react';
import { StateHistoryStats } from '@/lib/types';
interface ServerConfigurationTabsProps {
serverId: string;
configurations: Configurations;
statistics: StateHistoryStats;
}
export function ServerConfigurationTabs({
serverId,
configurations,
statistics
}: ServerConfigurationTabsProps) {
const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
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: '🔧' }
];
const renderTabContent = () => {
switch (currentTab) {
case ServerTab.statistics:
return <StatisticsDashboard stats={statistics} />;
case ServerTab.configuration:
return <ConfigurationEditor serverId={serverId} config={configurations.configuration} />;
case ServerTab.assistRules:
return <AssistRulesEditor serverId={serverId} config={configurations.assistRules} />;
case ServerTab.event:
return <EventConfigEditor serverId={serverId} config={configurations.event} />;
case ServerTab.eventRules:
return <EventRulesEditor serverId={serverId} config={configurations.eventRules} />;
case ServerTab.settings:
return <ServerSettingsEditor serverId={serverId} config={configurations.settings} />;
default:
return (
<div className="py-12 text-center">
<div className="mb-4 text-6xl">🚧</div>
<h3 className="mb-2 text-xl font-semibold text-white">Tab Not Found</h3>
<p className="text-gray-400">The requested tab could not be found.</p>
</div>
);
}
};
return (
<div className="overflow-hidden rounded-lg bg-gray-800">
<div className="border-b border-gray-700">
<nav className="flex space-x-8 overflow-x-auto" aria-label="Tabs">
{tabs.map((tab) => {
const isActive = currentTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setCurrentTab(tab.id)}
className={`flex items-center space-x-2 border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap transition-colors ${
isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300'
} `}
>
<span className="text-base">{tab.icon}</span>
<span>{tab.name}</span>
</button>
);
})}
</nav>
</div>
<div className="p-6">{renderTabContent()}</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import Link from 'next/link';
import { Server, getStatusColor, serviceStatusToString, ServiceStatus } from '@/lib/types/server';
import { startServerAction, stopServerAction, restartServerAction } from '@/lib/actions/servers';
interface ServerHeaderProps {
server: Server;
}
export function ServerHeader({ server }: ServerHeaderProps) {
return (
<div className="rounded-lg bg-gray-800 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link
href="/dashboard"
className="flex items-center text-gray-400 transition-colors hover:text-white"
>
<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="mt-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">{server.name}</h1>
<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={startServerAction.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={restartServerAction.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={stopServerAction.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>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
interface DailyActivity {
date: string;
sessionsCount: number;
}
interface DailyActivityChartProps {
data: DailyActivity[];
}
export function DailyActivityChart({ data }: DailyActivityChartProps) {
const chartData = {
labels: data.map((item) => new Date(item.date).toLocaleDateString()),
datasets: [
{
label: 'Sessions',
data: data.map((item) => item.sessionsCount),
backgroundColor: 'rgba(59, 130, 246, 0.8)',
borderColor: 'rgb(59, 130, 246)',
borderWidth: 1
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: '#e5e7eb'
}
}
},
scales: {
x: {
ticks: {
color: '#9ca3af'
},
grid: {
color: '#374151'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#9ca3af',
stepSize: 1
},
grid: {
color: '#374151'
}
}
}
};
return (
<div className="h-64">
<Bar data={chartData} options={options} />
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
TimeScale
} from 'chart.js';
import 'chartjs-adapter-date-fns';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
TimeScale
);
interface PlayerCountPoint {
timestamp: string;
count: number;
}
interface PlayerCountChartProps {
data: PlayerCountPoint[];
}
export function PlayerCountChart({ data }: PlayerCountChartProps) {
const chartData = {
datasets: [
{
label: 'Player Count',
data: data.map((point) => ({
x: new Date(point.timestamp),
y: point.count
})),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: '#e5e7eb'
}
}
},
scales: {
x: {
type: 'time' as const,
time: {
unit: 'hour' as const
},
ticks: {
color: '#9ca3af'
},
grid: {
color: '#374151'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#9ca3af',
stepSize: 1
},
grid: {
color: '#374151'
}
}
}
};
return (
<div className="h-64">
<Line data={chartData} options={options} />
</div>
);
}

View File

@@ -0,0 +1,85 @@
interface RecentSession {
id: number;
date: string;
type: string;
track: string;
duration: number;
players: number;
}
interface RecentSessionsProps {
sessions: RecentSession[];
}
export function RecentSessions({ sessions }: RecentSessionsProps) {
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (sessions.length === 0) {
return (
<div className="py-8 text-center">
<div className="mb-2 text-4xl">📊</div>
<p className="text-gray-400">No recent sessions found</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-700">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Track
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
Players
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{sessions.map((session) => (
<tr key={session.id} className="transition-colors hover:bg-gray-700">
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{formatDate(session.date)}
</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
<span className="inline-flex items-center rounded-full bg-blue-900 px-2.5 py-0.5 text-xs font-medium text-blue-300">
{session.type}
</span>
</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">{session.track}</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{formatDuration(session.duration)}
</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
{session.players}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend);
interface SessionCount {
name: string;
count: number;
}
interface SessionTypesChartProps {
data: SessionCount[];
}
export function SessionTypesChart({ data }: SessionTypesChartProps) {
const colors = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#06b6d4' // cyan
];
const chartData = {
labels: data.map((item) => item.name),
datasets: [
{
data: data.map((item) => item.count),
backgroundColor: colors.slice(0, data.length),
borderColor: colors.slice(0, data.length).map((color) => color + '20'),
borderWidth: 2
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
color: '#e5e7eb',
padding: 20
}
},
tooltip: {
callbacks: {
label: function (context: { label: string; parsed: number }) {
const total = data.reduce((sum, item) => sum + item.count, 0);
const percentage = ((context.parsed / total) * 100).toFixed(1);
return `${context.label}: ${context.parsed} (${percentage}%)`;
}
}
}
}
};
return (
<div className="h-64">
<Doughnut data={chartData} options={options} />
</div>
);
}

View File

@@ -0,0 +1,21 @@
interface StatCardProps {
title: string;
value: string | number;
icon: string;
}
export function StatCard({ title, value, icon }: StatCardProps) {
return (
<div className="rounded-lg bg-gray-800 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-3xl">{icon}</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>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import type { StateHistoryStats } from '@/lib/types/statistics';
import { PlayerCountChart } from './PlayerCountChart';
import { SessionTypesChart } from './SessionTypesChart';
import { DailyActivityChart } from './DailyActivityChart';
import { StatCard } from './StatCard';
import { RecentSessions } from './RecentSessions';
interface StatisticsDashboardProps {
stats: StateHistoryStats;
}
export function StatisticsDashboard({ stats }: StatisticsDashboardProps) {
if (!stats) {
return (
<div className="py-12 text-center">
<div className="mb-4 text-6xl">📊</div>
<h3 className="mb-2 text-xl font-semibold text-white">No Statistics Available</h3>
<p className="text-gray-400">No data found for the selected date range.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard title="Total Sessions" value={stats.totalSessions ?? 0} icon="🏁" />
<StatCard
title="Total Playtime"
value={`${Math.round(stats.totalPlaytime ?? 0 / 60)}h`}
icon="⏱️"
/>
<StatCard
title="Average Players"
value={(stats.averagePlayers ?? 0).toFixed(1)}
icon="👥"
/>
<StatCard title="Peak Players" value={stats.peakPlayers ?? 0} icon="🔥" />
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-medium text-white">Player Count Over Time</h3>
<PlayerCountChart data={stats.playerCountOverTime ?? []} />
</div>
<div className="rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-medium text-white">Session Types</h3>
<SessionTypesChart data={stats.sessionTypes ?? []} />
</div>
</div>
<div className="rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-medium text-white">Daily Activity</h3>
<DailyActivityChart data={stats.dailyActivity ?? []} />
</div>
{/* Recent Sessions */}
<div className="rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-medium text-white">Recent Sessions</h3>
<RecentSessions sessions={stats.recentSessions ?? []} />
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { LoadingSpinner } from './LoadingSpinner';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props },
ref
) => {
const baseClasses =
'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 = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500'
};
const sizes = {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
return (
<button
className={cn(baseClasses, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
ref={ref}
{...props}
>
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,29 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, ...props }, ref) => {
return (
<div className="space-y-1">
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
<input
className={cn(
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
error && 'border-red-500 focus:ring-red-500',
className
)}
ref={ref}
{...props}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
}
export function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
};
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-gray-300 border-t-transparent',
sizeClasses[size],
className
)}
/>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { ReactNode, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
className?: string;
}
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="bg-opacity-50 absolute inset-0 bg-black" onClick={onClose} />
<div
className={cn(
'relative mx-4 w-full max-w-md rounded-lg bg-gray-800 p-6 shadow-lg',
className
)}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white">
×
</button>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
const getVisiblePages = () => {
const delta = 2;
const range = [];
for (
let i = Math.max(2, currentPage - delta);
i <= Math.min(totalPages - 1, currentPage + delta);
i++
) {
range.push(i);
}
if (currentPage - delta > 2) {
range.unshift('...');
}
if (currentPage + delta < totalPages - 1) {
range.push('...');
}
range.unshift(1);
if (totalPages !== 1) {
range.push(totalPages);
}
return range;
};
return (
<nav className="flex items-center justify-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<div className="flex space-x-1">
{getVisiblePages().map((page, index) => (
<button
key={index}
onClick={() => (typeof page === 'number' ? onPageChange(page) : undefined)}
disabled={typeof page === 'string'}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
page === currentPage
? 'bg-blue-600 text-white'
: page === '...'
? 'cursor-default text-gray-400'
: 'bg-gray-700 text-white hover:bg-gray-600'
} `}
>
{page}
</button>
))}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</nav>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
export default function RefreshButton() {
return (
<button
onClick={() => window.location.reload()}
className="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
>
Refresh
</button>
);
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, SelectHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string | number; label: string }[];
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, options, ...props }, ref) => {
return (
<div className="space-y-1">
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
<select
className={cn(
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
error && 'border-red-500 focus:ring-red-500',
className
)}
ref={ref}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
);
}
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,49 @@
'use client';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
export function Toast({ message, type, duration = 5000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
if (!isVisible) return null;
return (
<div
className={cn('fixed top-4 right-4 z-50 max-w-md rounded-md p-4 shadow-lg', {
'bg-green-600 text-white': type === 'success',
'bg-red-600 text-white': type === 'error',
'bg-blue-600 text-white': type === 'info'
})}
>
<div className="flex items-center justify-between">
<p className="text-sm">{message}</p>
<button
onClick={() => {
setIsVisible(false);
onClose?.();
}}
className="ml-4 text-white hover:opacity-80"
>
×
</button>
</div>
</div>
);
}

View File

@@ -1,70 +0,0 @@
import type { Handle } from '@sveltejs/kit';
import { redisSessionManager } from '$stores/redisSessionManager';
import { env } from '$env/dynamic/private';
import type Redis from 'ioredis';
const USER_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes
interface SessionData {
token?: string;
user?: any;
userFetchedAt?: number;
}
export const handle: Handle = async ({ event, resolve }) => {
// Ensure redis is connected
const redisClient: Redis = redisSessionManager['redisClient'];
if (redisClient.status !== 'connect' && redisClient.status !== 'ready') {
try {
await redisClient.connect();
} catch (err) {
console.error('Redis connection failed', err);
}
}
// Get session from cookie
const session = await redisSessionManager.getSession(event.cookies);
const sessionData: SessionData = session?.data || {};
if (!sessionData.token) {
event.locals.user = null;
return resolve(event);
}
// Check if cached user data is still valid
if (sessionData.user && sessionData.userFetchedAt) {
const isExpired = Date.now() - sessionData.userFetchedAt > USER_CACHE_DURATION;
if (!isExpired) {
event.locals.user = sessionData.user;
return resolve(event);
}
}
// Fetch fresh user data
try {
const response = await fetch(`${env.API_BASE_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${sessionData.token}`
}
});
if (response.ok) {
const user = await response.json();
event.locals.user = user;
// Cache user data in session
sessionData.user = user;
sessionData.userFetchedAt = Date.now();
await redisSessionManager.createSession(event.cookies, sessionData, user.id);
} else {
// Token invalid, clear session
event.locals.user = null;
await redisSessionManager.deleteCookie(event.cookies);
}
} catch (error) {
console.error('Failed to fetch user:', error);
event.locals.user = null;
}
return resolve(event);
};

33
src/lib/actions/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
'use server';
import { redirect } from 'next/navigation';
import { loginUser } from '@/lib/api/server/auth';
import { login, logout } from '@/lib/auth/server';
export async function loginAction(formData: FormData) {
try {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
throw new Error('Username and password are required');
}
const result = await loginUser(username, password);
if (result.token && result.user) {
await login(result.token, result.user);
} else {
throw new Error('Invalid credentials');
}
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Authentication failed');
}
redirect('/dashboard');
}
export async function logoutAction() {
await logout();
redirect('/login');
}

View File

@@ -0,0 +1,197 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration } from '@/lib/api/server/configuration';
import { ConfigFile } from '@/lib/types/config';
import type {
Configuration,
AssistRules,
EventConfig,
EventRules,
ServerSettings
} from '@/lib/types/config';
export async function updateConfigurationAction(serverId: string, formData: FormData) {
try {
const session = await requireAuth();
const restart = formData.get('restart') === 'on';
const config: Configuration = {
udpPort: parseInt(formData.get('udpPort') as string),
tcpPort: parseInt(formData.get('tcpPort') as string),
maxConnections: parseInt(formData.get('maxConnections') as string),
lanDiscovery: parseInt(formData.get('lanDiscovery') as string),
registerToLobby: parseInt(formData.get('registerToLobby') as string),
configVersion: parseInt(formData.get('configVersion') as string) || 1
};
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.configuration,
config,
restart
);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Configuration updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update configuration'
};
}
}
export async function updateAssistRulesAction(serverId: string, formData: FormData) {
try {
const session = await requireAuth();
const restart = formData.get('restart') === 'on';
const config: AssistRules = {
stabilityControlLevelMax: parseInt(formData.get('stabilityControlLevelMax') as string),
disableAutosteer: parseInt(formData.get('disableAutosteer') as string),
disableAutoLights: parseInt(formData.get('disableAutoLights') as string),
disableAutoWiper: parseInt(formData.get('disableAutoWiper') as string),
disableAutoEngineStart: parseInt(formData.get('disableAutoEngineStart') as string),
disableAutoPitLimiter: parseInt(formData.get('disableAutoPitLimiter') as string),
disableAutoGear: parseInt(formData.get('disableAutoGear') as string),
disableAutoClutch: parseInt(formData.get('disableAutoClutch') as string),
disableIdealLine: parseInt(formData.get('disableIdealLine') as string)
};
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.assistRules,
config,
restart
);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Assist rules updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update assist rules'
};
}
}
export async function updateServerSettingsAction(serverId: string, formData: FormData) {
try {
const session = await requireAuth();
const restart = formData.get('restart') === 'on';
const config: ServerSettings = {
serverName: formData.get('serverName') as string,
adminPassword: formData.get('adminPassword') as string,
carGroup: formData.get('carGroup') as string,
trackMedalsRequirement: parseInt(formData.get('trackMedalsRequirement') as string),
safetyRatingRequirement: parseInt(formData.get('safetyRatingRequirement') as string),
racecraftRatingRequirement: parseInt(formData.get('racecraftRatingRequirement') as string),
password: formData.get('password') as string,
spectatorPassword: formData.get('spectatorPassword') as string,
maxCarSlots: parseInt(formData.get('maxCarSlots') as string),
dumpLeaderboards: parseInt(formData.get('dumpLeaderboards') as string),
isRaceLocked: parseInt(formData.get('isRaceLocked') as string),
randomizeTrackWhenEmpty: parseInt(formData.get('randomizeTrackWhenEmpty') as string),
centralEntryListPath: formData.get('centralEntryListPath') as string,
allowAutoDQ: parseInt(formData.get('allowAutoDQ') as string),
shortFormationLap: parseInt(formData.get('shortFormationLap') as string),
formationLapType: parseInt(formData.get('formationLapType') as string),
ignorePrematureDisconnects: parseInt(formData.get('ignorePrematureDisconnects') as string)
};
await updateServerConfiguration(session.token!, serverId, ConfigFile.settings, config, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Server settings updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update server settings'
};
}
}
export async function updateEventConfigAction(serverId: string, formData: FormData) {
try {
const session = await requireAuth();
const restart = formData.get('restart') === 'on';
const sessionsData = formData.get('sessions') as string;
const sessions = sessionsData ? JSON.parse(sessionsData) : [];
const config: EventConfig = {
track: formData.get('track') as string,
preRaceWaitingTimeSeconds: parseInt(formData.get('preRaceWaitingTimeSeconds') as string),
sessionOverTimeSeconds: parseInt(formData.get('sessionOverTimeSeconds') as string),
ambientTemp: parseInt(formData.get('ambientTemp') as string),
cloudLevel: parseFloat(formData.get('cloudLevel') as string),
rain: parseFloat(formData.get('rain') as string),
weatherRandomness: parseInt(formData.get('weatherRandomness') as string),
postQualySeconds: parseInt(formData.get('postQualySeconds') as string),
postRaceSeconds: parseInt(formData.get('postRaceSeconds') as string),
simracerWeatherConditions: parseInt(formData.get('simracerWeatherConditions') as string),
isFixedConditionQualification: parseInt(
formData.get('isFixedConditionQualification') as string
),
sessions
};
await updateServerConfiguration(session.token!, serverId, ConfigFile.event, config, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return {
success: true,
message: 'Event configuration updated successfully'
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update event configuration'
};
}
}
export async function updateEventRulesAction(serverId: string, formData: FormData) {
try {
const session = await requireAuth();
const restart = formData.get('restart') === 'on';
const config: EventRules = {
qualifyStandingType: parseInt(formData.get('qualifyStandingType') as string),
pitWindowLengthSec: parseInt(formData.get('pitWindowLengthSec') as string),
driverStintTimeSec: parseInt(formData.get('driverStintTimeSec') as string),
mandatoryPitstopCount: parseInt(formData.get('mandatoryPitstopCount') as string),
maxTotalDrivingTime: parseInt(formData.get('maxTotalDrivingTime') as string),
isRefuellingAllowedInRace: formData.get('isRefuellingAllowedInRace') === 'true',
isRefuellingTimeFixed: formData.get('isRefuellingTimeFixed') === 'true',
isMandatoryPitstopRefuellingRequired:
formData.get('isMandatoryPitstopRefuellingRequired') === 'true',
isMandatoryPitstopTyreChangeRequired:
formData.get('isMandatoryPitstopTyreChangeRequired') === 'true',
isMandatoryPitstopSwapDriverRequired:
formData.get('isMandatoryPitstopSwapDriverRequired') === 'true',
tyreSetCount: parseInt(formData.get('tyreSetCount') as string)
};
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.eventRules,
config,
restart
);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'Event rules updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update event rules'
};
}
}

View File

@@ -0,0 +1,41 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { createUser, deleteUser } from '@/lib/api/server/membership';
export async function createUserAction(formData: FormData) {
try {
const session = await requireAuth();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
const role = formData.get('role') as string;
await createUser(session.token!, { username, password, role });
revalidatePath('/dashboard/membership');
return { success: true, message: 'User created successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to create user'
};
}
}
export async function deleteUserAction(formData: FormData) {
try {
const session = await requireAuth();
const userId = formData.get('id') as string;
await deleteUser(session.token!, userId);
revalidatePath('/dashboard/membership');
return { success: true, message: 'User deleted successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete user'
};
}
}

View File

@@ -0,0 +1,38 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { startService, stopService, restartService } from '@/lib/api/server/servers';
export async function startServerAction(serverId: string) {
try {
const session = await requireAuth();
await startService(session.token!, serverId);
revalidatePath('/dashboard');
revalidatePath(`/dashboard/server/${serverId}`);
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Failed to start server');
}
}
export async function stopServerAction(serverId: string) {
try {
const session = await requireAuth();
await stopService(session.token!, serverId);
revalidatePath('/dashboard');
revalidatePath(`/dashboard/server/${serverId}`);
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Failed to stop server');
}
}
export async function restartServerAction(serverId: string) {
try {
const session = await requireAuth();
await restartService(session.token!, serverId);
revalidatePath('/dashboard');
revalidatePath(`/dashboard/server/${serverId}`);
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Failed to restart server');
}
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
async function fetchFromAPI(endpoint: string, method: string = 'GET', body?: unknown) {
const response = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
export function useServerStatus(serverId: string) {
return useQuery({
queryKey: ['server-status', serverId],
queryFn: () => fetchFromAPI(`/api/server/${serverId}/status`),
refetchInterval: 30000
});
}
export function useServerAction(serverId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ action }: { action: 'start' | 'stop' | 'restart' }) =>
fetchFromAPI(`/api/server/${serverId}/action`, 'POST', { action }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-status', serverId] });
queryClient.invalidateQueries({ queryKey: ['servers'] });
}
});
}
export function useUsers(params?: Record<string, string>) {
const searchParams = new URLSearchParams(params).toString();
const endpoint = `/api/membership${searchParams ? `?${searchParams}` : ''}`;
return useQuery({
queryKey: ['users', params],
queryFn: () => fetchFromAPI(endpoint)
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: { username: string; password: string; role: string }) =>
fetchFromAPI('/api/membership', 'POST', userData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => fetchFromAPI(`/api/membership/${userId}`, 'DELETE'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
}

View File

@@ -0,0 +1,12 @@
'use client';
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false
}
}
});

View File

@@ -0,0 +1,30 @@
import { User } from '@/lib/types';
import { fetchServerAPI } from './base';
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
const authRoute = '/auth';
export async function loginUser(username: string, password: string) {
const response = await fetch(`${BASE_URL}${authRoute}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error(`Login failed: ${response.statusText} - ${BASE_URL}${authRoute}/login`);
}
const { token } = await response.json();
const userResponse = await getCurrentUser(token);
return { token, user: userResponse };
}
export async function getCurrentUser(token: string) {
return fetchServerAPI<User>(`${authRoute}/me`, token);
}

View File

@@ -0,0 +1,31 @@
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export async function fetchServerAPI<T>(
endpoint: string,
token: string,
method: string = 'GET',
body?: object
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
};
const response = await fetch(`${BASE_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(
`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint} - ${token}`
);
}
if (response.headers.get('Content-Type')?.includes('application/json')) {
return response.json();
}
return response.text() as T;
}

View File

@@ -0,0 +1,40 @@
import { fetchServerAPI } from './base';
import type {
Configurations,
Configuration,
AssistRules,
EventConfig,
EventRules,
ServerSettings,
ConfigFile
} from '@/lib/types/config';
const serverRoute = '/server';
export async function getServerConfigurations(
token: string,
serverId: string
): Promise<Configurations> {
return fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
}
export async function getServerConfiguration(
token: string,
serverId: string,
configType: ConfigFile
): Promise<Configuration | AssistRules | EventConfig | EventRules | ServerSettings> {
return fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}`, token);
}
export async function updateServerConfiguration(
token: string,
serverId: string,
configType: ConfigFile,
config: Configuration | AssistRules | EventConfig | EventRules | ServerSettings,
restart = false
): Promise<void> {
await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}`, token, 'PUT', {
...config,
restart
});
}

View File

@@ -0,0 +1,24 @@
import { fetchServerAPI } from './base';
import { Track, CarModel, CupCategory, DriverCategory, SessionType } from '@/lib/types';
const lookupRoute = '/lookup';
export async function getTracks(token: string): Promise<Track[]> {
return fetchServerAPI(`${lookupRoute}/tracks`, token);
}
export async function getCarModels(token: string): Promise<CarModel[]> {
return fetchServerAPI(`${lookupRoute}/car-models`, token);
}
export async function getCupCategories(token: string): Promise<CupCategory[]> {
return fetchServerAPI(`${lookupRoute}/cup-categories`, token);
}
export async function getDriverCategories(token: string): Promise<DriverCategory[]> {
return fetchServerAPI(`${lookupRoute}/driver-categories`, token);
}
export async function getSessionTypes(token: string): Promise<SessionType[]> {
return fetchServerAPI(`${lookupRoute}/session-types`, token);
}

View File

@@ -0,0 +1,51 @@
import { fetchServerAPI } from './base';
import { User, Role } from '@/lib/types';
export interface UserListParams {
username?: string;
role_name?: string;
sort_by?: string;
sort_desc?: boolean;
page?: number;
limit?: number;
}
const membershipRoute = '/membership';
export async function getUsers(token: string, params: UserListParams = {}): Promise<User[]> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.set(key, value.toString());
}
});
const queryString = searchParams.toString();
const endpoint = `${membershipRoute}${queryString ? `?${queryString}` : ''}`;
return fetchServerAPI(endpoint, token);
}
export async function createUser(
token: string,
userData: { username: string; password: string; role: string }
) {
return fetchServerAPI(membershipRoute, token, 'POST', userData);
}
export async function getUserById(token: string, userId: string): Promise<User> {
return fetchServerAPI(`${membershipRoute}/${userId}`, token);
}
export async function updateUser(token: string, userId: string, userData: Partial<User>) {
return fetchServerAPI(`${membershipRoute}/${userId}`, token, 'PUT', userData);
}
export async function deleteUser(token: string, userId: string) {
return fetchServerAPI(`${membershipRoute}/${userId}`, token, 'DELETE');
}
export async function getRoles(token: string): Promise<Role[]> {
return fetchServerAPI(`${membershipRoute}/roles`, token);
}

View File

@@ -0,0 +1,30 @@
import { fetchServerAPI } from './base';
import { Server } from '@/lib/types/server';
const serverRoute = '/server';
export async function getServers(token: string): Promise<Server[]> {
const response = await fetchServerAPI<Server[]>(serverRoute, token);
return response;
}
export async function getServer(token: string, serverId: string): Promise<Server> {
const response = await fetchServerAPI<Server>(`${serverRoute}/${serverId}`, token);
return response;
}
export async function restartService(token: string, serverId: string) {
return fetchServerAPI(`${serverRoute}/${serverId}/service/restart`, token, 'POST');
}
export async function startService(token: string, serverId: string) {
return fetchServerAPI(`${serverRoute}/${serverId}/service/start`, token, 'POST');
}
export async function stopService(token: string, serverId: string) {
return fetchServerAPI(`${serverRoute}/${serverId}/service/stop`, token, 'POST');
}
export async function getServiceStatus(token: string, serverId: string) {
return fetchServerAPI(`${serverRoute}/${serverId}/service`, token);
}

View File

@@ -0,0 +1,16 @@
import { fetchServerAPI } from './base';
import type { StateHistoryStats } from '@/lib/types/statistics';
const serverRoute = '/server';
export async function getServerStatistics(
token: string,
serverId: string,
startDate: string,
endDate: string
): Promise<StateHistoryStats> {
return fetchServerAPI<StateHistoryStats>(
`${serverRoute}/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`,
token
);
}

31
src/lib/auth/server.ts Normal file
View File

@@ -0,0 +1,31 @@
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { SessionData, sessionOptions } from '@/lib/session/config';
import { redirect } from 'next/navigation';
export async function getSession() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
return session;
}
export async function requireAuth() {
const session = await getSession();
if (!session.token || !session.user) {
redirect('/login');
}
return session;
}
export async function login(token: string, user: SessionData['user']) {
const session = await getSession();
session.token = token;
session.user = user;
await session.save();
}
export async function logout() {
const session = await getSession();
session.destroy();
}

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

17
src/lib/session/config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { SessionOptions } from 'iron-session';
import { User } from '@/lib/types';
export interface SessionData {
token?: string;
user?: User;
}
export const sessionOptions: SessionOptions = {
password: process.env.SECRET || 'development-secret-key-must-be-32-characters-long',
cookieName: 'acc-session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 30 * 24 * 60 * 60 // 30 days
}
};

View File

@@ -1,21 +1,13 @@
export interface Configurations {
configuration: Configuration;
assistRules: AssistRules;
event: EventConfig;
eventRules: EventRules;
settings: ServerSettings;
}
export enum configFile {
export enum ConfigFile {
configuration = 'configuration.json',
assistRules = 'assistRules.json',
event = 'event.json',
eventRules = 'eventRules.json',
settings = 'settings.json'
}
export enum serverTab {
export enum ServerTab {
statistics = 'statistics',
statistics2 = 'statistics2',
configuration = 'configuration',
assistRules = 'assistRules',
event = 'event',
@@ -23,58 +15,16 @@ export enum serverTab {
settings = 'settings'
}
export interface StateHistory {
dateCreated: string;
sessionStart: string;
playerCount: number;
track: string;
sessionDurationMinutes: number;
session: string;
// Configuration interfaces
export interface Configuration {
udpPort: number;
tcpPort: number;
maxConnections: number;
lanDiscovery: number;
registerToLobby: number;
configVersion: number;
}
interface SessionCount {
name: string;
count: number;
}
interface DailyActivity {
date: string; // ISO 8601 date string
sessionsCount: number;
}
interface PlayerCountPoint {
timestamp: string; // ISO 8601 datetime string
count: number;
}
interface RecentSession {
id: number;
date: string;
type: string;
track: string;
duration: number;
players: number;
}
export interface StateHistoryStats {
averagePlayers: number;
peakPlayers: number;
totalSessions: number;
totalPlaytime: number; // in minutes
playerCountOverTime: PlayerCountPoint[];
sessionTypes: SessionCount[];
dailyActivity: DailyActivity[];
recentSessions: RecentSession[];
}
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
export type ConfigFile =
| configFile.configuration
| configFile.assistRules
| configFile.event
| configFile.eventRules
| configFile.settings;
export interface AssistRules {
stabilityControlLevelMax: number;
disableAutosteer: number;
@@ -107,13 +57,12 @@ export interface ServerSettings {
ignorePrematureDisconnects: number;
}
export interface Configuration {
udpPort: number;
tcpPort: number;
maxConnections: number;
lanDiscovery: number;
registerToLobby: number;
configVersion: number;
export interface Session {
hourOfDay: number;
dayOfWeekend: number;
timeMultiplier: number;
sessionType: string;
sessionDurationMinutes: number;
}
export interface EventConfig {
@@ -131,14 +80,6 @@ export interface EventConfig {
sessions: Session[];
}
export interface Session {
hourOfDay: number;
dayOfWeekend: number;
timeMultiplier: number;
sessionType: string;
sessionDurationMinutes: number;
}
export interface EventRules {
qualifyStandingType: number;
pitWindowLengthSec: number;
@@ -152,3 +93,13 @@ export interface EventRules {
isMandatoryPitstopSwapDriverRequired: boolean;
tyreSetCount: number;
}
export interface Configurations {
configuration: Configuration;
assistRules: AssistRules;
event: EventConfig;
eventRules: EventRules;
settings: ServerSettings;
}
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;

6
src/lib/types/index.ts Normal file
View File

@@ -0,0 +1,6 @@
// Re-export all types for easier imports
export * from './server';
export * from './user';
export * from './config';
export * from './statistics';
export * from './lookups';

View File

@@ -1,32 +1,56 @@
import type { ServiceStatus } from './serviceStatus';
export enum ServiceStatus {
Unknown,
Stopped,
Stopping,
Restarting,
Starting,
Running
}
export interface ServerState {
export const serviceStatusToString = (status: ServiceStatus): string => {
switch (status) {
case ServiceStatus.Running:
return 'Running';
case ServiceStatus.Stopped:
return 'Stopped';
case ServiceStatus.Starting:
return 'Starting';
case ServiceStatus.Stopping:
return 'Stopping';
case ServiceStatus.Restarting:
return 'Restarting';
default:
return 'Unknown';
}
};
export const getStatusColor = (status: ServiceStatus): string => {
switch (status) {
case ServiceStatus.Running:
return 'bg-green-500';
case ServiceStatus.Stopped:
return 'bg-red-500';
case ServiceStatus.Starting:
return 'bg-blue-500';
case ServiceStatus.Stopping:
return 'bg-yellow-500';
case ServiceStatus.Restarting:
return 'bg-purple-500';
default:
return 'bg-gray-500';
}
};
interface State {
session: string;
sessionStart: string;
playerCount: number;
track: string;
maxConnections: number;
}
export interface Server {
id: number;
id: string;
name: string;
status: ServiceStatus;
state: ServerState;
state: State;
}
// Helper function to parse server data from API
export const parseServer = (data: any): Server => {
return {
id: data.id,
name: data.name,
status: data.status,
state: {
session: data.state.session,
sessionStart: data.state.sessionStart,
playerCount: data.state.playerCount,
track: data.state.track,
maxConnections: data.state.maxConnections
}
};
};

View File

@@ -1,59 +0,0 @@
export enum ServiceStatus {
Unknown,
Stopped,
Stopping,
Restarting,
Starting,
Running
}
export const serviceStatusToString = (status: ServiceStatus): string => {
switch (status) {
case ServiceStatus.Running:
return 'Running';
case ServiceStatus.Stopped:
return 'Stopped';
case ServiceStatus.Starting:
return 'Starting';
case ServiceStatus.Stopping:
return 'Stopping';
case ServiceStatus.Restarting:
return 'Restarting';
default:
return 'Unknown';
}
};
export const parseServiceStatus = (status: string): ServiceStatus => {
switch (status) {
case 'Running':
return ServiceStatus.Running;
case 'SERVICE_STOPPED':
return ServiceStatus.Stopped;
case 'SERVICE_STARTING':
return ServiceStatus.Starting;
case 'SERVICE_STOPPING':
return ServiceStatus.Stopping;
case 'SERVICE_RESTARTING':
return ServiceStatus.Restarting;
default:
return ServiceStatus.Unknown;
}
};
export const getStatusColor = (status: ServiceStatus): string => {
switch (status) {
case ServiceStatus.Running:
return 'bg-green-500';
case ServiceStatus.Stopped:
return 'bg-red-500';
case ServiceStatus.Starting:
return 'bg-blue-500';
case ServiceStatus.Stopping:
return 'bg-yellow-500';
case ServiceStatus.Restarting:
return 'bg-purple-500';
default:
return 'bg-gray-500';
}
};

View File

@@ -0,0 +1,43 @@
export interface StateHistory {
dateCreated: string;
sessionStart: string;
playerCount: number;
track: string;
sessionDurationMinutes: number;
session: string;
}
interface SessionCount {
name: string;
count: number;
}
interface DailyActivity {
date: string;
sessionsCount: number;
}
interface PlayerCountPoint {
timestamp: string;
count: number;
}
interface RecentSession {
id: number;
date: string;
type: string;
track: string;
duration: number;
players: number;
}
export interface StateHistoryStats {
averagePlayers: number;
peakPlayers: number;
totalSessions: number;
totalPlaytime: number;
playerCountOverTime: PlayerCountPoint[];
sessionTypes: SessionCount[];
dailyActivity: DailyActivity[];
recentSessions: RecentSession[];
}

View File

@@ -1,7 +1,20 @@
import { writable, type Writable } from 'svelte/store';
import type { User } from '$models/user';
export interface Permission {
id: string;
name: string;
}
export const user: Writable<User | null> = writable(null);
export interface Role {
id: string;
name: string;
permissions: Permission[];
}
export interface User {
id: string;
username: string;
role_id: string;
role: Role;
}
export function hasPermission(user: User | null, permission: string): boolean {
if (!user || !user.role || !user.role.permissions) {

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,15 +0,0 @@
import type { ServiceStatus } from '$lib/types/serviceStatus';
interface State {
session: string;
playerCount: number;
track: string;
maxConnections: number;
}
export interface Server {
id: string;
name: string;
status: ServiceStatus;
state: State;
}

View File

@@ -1,17 +0,0 @@
export interface Permission {
id: string;
name: string;
}
export interface Role {
id: string;
name: string;
permissions: Permission[];
}
export interface User {
id: string;
username: string;
role_id: string;
role: Role;
}

View File

@@ -1,7 +0,0 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import '../app.css';
import { user } from '$stores/user';
import type { LayoutData } from './$types';
let { data, children } = $props<LayoutData>();
// Set the user store with data from the server
$effect(() => {
$user = data.user;
});
</script>
<div class="layout">
{@render children()}
</div>
<svelte:head>
<title>ACC Server Manager</title>
</svelte:head>
<style></style>

View File

@@ -1,9 +0,0 @@
import { checkAuth } from '$api/authService';
import type { RequestEvent } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
export const load = async (event: RequestEvent) => {
const isAuth = await checkAuth(event);
if (isAuth) redirect(308, '/dashboard');
redirect(308, '/login');
};

View File

@@ -1,27 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const { url } = event;
const queryParams = url.searchParams.toString();
const endpoint = `/membership${queryParams ? `?${queryParams}` : ''}`;
const users = await fetchAPIEvent(event, endpoint);
return json(users);
} catch (error) {
console.error('Failed to fetch users:', error);
return json({ error: 'Failed to fetch users' }, { status: 500 });
}
}
export async function POST(event: RequestEvent) {
try {
const userData = await event.request.json();
const user = await fetchAPIEvent(event, '/membership', 'POST', userData);
return json(user);
} catch (error) {
console.error('Failed to create user:', error);
return json({ error: 'Failed to create user' }, { status: 500 });
}
}

View File

@@ -1,42 +0,0 @@
import { fetchAPIEvent } from '$api/apiService';
import { json, type RequestEvent } from '@sveltejs/kit';
export async function GET(event: RequestEvent) {
try {
const { params } = event;
const userId = params.id;
const user = await fetchAPIEvent(event, `/membership/${userId}`);
return json(user);
} catch (error) {
console.error('Failed to fetch user:', error);
return json({ error: 'Failed to fetch user' }, { status: 500 });
}
}
export async function PUT(event: RequestEvent) {
try {
const { params } = event;
const userId = params.id;
const userData = await event.request.json();
const user = await fetchAPIEvent(event, `/membership/${userId}`, 'PUT', userData);
return json(user);
} catch (error) {
console.error('Failed to update user:', error);
return json({ error: 'Failed to update user' }, { status: 500 });
}
}
export async function DELETE(event: RequestEvent) {
try {
const { params } = event;
const userId = params.id;
await fetchAPIEvent(event, `/membership/${userId}`, 'DELETE');
return json({ success: true });
} catch (error) {
console.error('Failed to delete user:', error);
return json({ error: 'Failed to delete user' }, { status: 500 });
}
}

View File

@@ -1,46 +0,0 @@
import { checkAuth } from '$api/authService';
import { getServers, restartService, startService, stopService } from '$api/serverService';
import { fail, redirect, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load = async (event: RequestEvent) => {
const servers = await getServers(event);
return { servers };
};
// Helper function to create a server action with validation and error handling
const createServerAction = (
action: (event: RequestEvent, id: string) => Promise<void>,
{ success, failure }: { success: string; failure: string }
) => {
return async (event: RequestEvent) => {
const formData = await event.request.formData();
const id = formData.get('id');
if (!id || typeof id !== 'string') {
return fail(400, { message: 'Invalid server ID provided.' });
}
try {
await action(event, id);
return { success: true, message: success };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return fail(500, { message: `${failure}: ${message}` });
}
};
};
export const actions = {
start: createServerAction(startService, {
success: 'Server started successfully.',
failure: 'Failed to start server'
}),
restart: createServerAction(restartService, {
success: 'Server restarted successfully.',
failure: 'Failed to restart server'
}),
stop: createServerAction(stopService, {
success: 'Server stopped successfully.',
failure: 'Failed to stop server'
})
} satisfies Actions;

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import ServerCard from '$components/ServerCard.svelte';
import Toast from '$components/Toast.svelte';
import type { Server } from '$models/server';
import { user, hasPermission } from '$stores/user';
const { data, form } = $props();
let servers: Server[] = data.servers;
</script>
{#if form?.message}
<Toast message={form.message} type={form.success ? 'success' : 'error'} />
{/if}
<div class="min-h-screen bg-gray-900 text-white">
<header class="bg-gray-800 shadow-md">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<h1 class="text-2xl font-bold">ACC Server Manager</h1>
<div class="flex items-center space-x-4">
{#if hasPermission($user, 'membership.view')}
<a href="/dashboard/membership" class="flex items-center text-gray-300 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="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 class="ml-1 hidden sm:inline">Users</span>
</a>
{/if}
<a href="/logout">
<button class="flex items-center text-gray-300 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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 class="ml-1 hidden sm:inline">Logout</span>
</button>
</a>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold">Your Servers</h2>
<div class="flex space-x-2">
<button
onclick={() => {
window.location.reload();
}}
class="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
>
Refresh
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each servers as server}
<ServerCard {server} />
{/each}
</div>
</main>
</div>
<svelte:head>
<title>ACC Server Manager - Dashboard</title>
</svelte:head>
<style>
</style>

View File

@@ -1,114 +0,0 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { membershipService } from '$api/membershipService';
export const load = async (event) => {
if (!event.locals.user) {
throw redirect(303, '/login');
}
// Get filter parameters from URL
const page = parseInt(event.url.searchParams.get('page') || '1');
const pageSize = parseInt(event.url.searchParams.get('page_size') || '10');
const username = event.url.searchParams.get('username') || '';
const roleName = event.url.searchParams.get('role_name') || '';
const sortBy = event.url.searchParams.get('sort_by') || 'username';
const sortDesc = event.url.searchParams.get('sort_desc') === 'true';
const filter = {
page,
page_size: pageSize,
username: username || undefined,
role_name: roleName || undefined,
sort_by: sortBy,
sort_desc: sortDesc
};
try {
const [users, roles] = await Promise.all([
membershipService.getUsers(event, filter),
membershipService.getRoles(event)
]);
// Simple client-side pagination
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedUsers = users.slice(startIndex, endIndex);
const totalPages = Math.ceil(users.length / pageSize);
return {
users: paginatedUsers,
roles,
pagination: {
page,
page_size: pageSize,
total: users.length,
total_pages: totalPages
},
filter: {
username,
role_name: roleName,
sort_by: sortBy,
sort_desc: sortDesc
}
};
} catch (error) {
console.error('Failed to load users or roles:', error);
return {
users: [],
roles: [],
pagination: {
page: 1,
page_size: 10,
total: 0,
total_pages: 0
},
filter: {
username: '',
role_name: '',
sort_by: 'username',
sort_desc: false
}
};
}
};
export const actions = {
create: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
const role = formData.get('role');
if (!username || !password || !role) {
return fail(400, { message: 'All fields are required' });
}
try {
await membershipService.createUser(event, {
username: username.toString(),
password: password.toString(),
role: role.toString()
});
return { success: true, message: 'User created successfully' };
} catch (error) {
console.error('Failed to create user:', error);
return fail(500, { message: 'Failed to create user' });
}
},
delete: async (event) => {
const formData = await event.request.formData();
const id = formData.get('id');
if (!id) {
return fail(400, { message: 'User ID is required' });
}
try {
await membershipService.deleteUser(event, id.toString());
return { success: true, message: 'User deleted successfully' };
} catch (error) {
console.error('Failed to delete user:', error);
return fail(500, { message: 'Failed to delete user' });
}
}
} satisfies Actions;

View File

@@ -1,379 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Pagination from '$components/Pagination.svelte';
import Toast from '$components/Toast.svelte';
import { user, hasPermission } from '$stores/user';
import type { PageData } from './$types';
const { data, form }: { data: PageData; form?: any } = $props();
let users = $derived(data.users || []);
let roles = $derived(data.roles || []);
let pagination = $derived(data.pagination);
let filter = $derived(data.filter);
let usernameFilter = $state('');
let roleNameFilter = $state('');
let sortBy = $state('username');
let sortDesc = $state(false);
let showCreateModal = $state(false);
let showDeleteModal = $state(false);
let selectedUser: any = $state(null);
let createForm = $state({
username: '',
password: '',
role: ''
});
// Initialize filters from data
$effect(() => {
usernameFilter = filter.username || '';
roleNameFilter = filter.role_name || '';
sortBy = filter.sort_by || 'username';
sortDesc = filter.sort_desc || false;
});
function applyFilters() {
const params = new URLSearchParams();
// Set filter parameters
if (usernameFilter) params.set('username', usernameFilter);
if (roleNameFilter) params.set('role_name', roleNameFilter);
params.set('sort_by', sortBy);
params.set('sort_desc', sortDesc.toString());
params.set('page', '1'); // Reset to first page
goto(`?${params.toString()}`, { invalidateAll: true });
}
function resetFilters() {
usernameFilter = '';
roleNameFilter = '';
sortBy = 'username';
sortDesc = false;
goto('/dashboard/membership', { invalidateAll: true });
}
function handlePageChange(newPage: number) {
const params = new URLSearchParams($page.url.searchParams);
params.set('page', newPage.toString());
goto(`?${params.toString()}`, { invalidateAll: true });
}
function handleSort(field: string) {
if (sortBy === field) {
sortDesc = !sortDesc;
} else {
sortBy = field;
sortDesc = false;
}
applyFilters();
}
function getSortIcon(field: string) {
if (sortBy !== field) return '↕️';
return sortDesc ? '↓' : '↑';
}
function openCreateModal() {
showCreateModal = true;
createForm = { username: '', password: '', role: '' };
}
function closeCreateModal() {
showCreateModal = false;
}
function openDeleteModal(userItem: any) {
selectedUser = userItem;
showDeleteModal = true;
}
function closeDeleteModal() {
selectedUser = null;
showDeleteModal = false;
}
// Close modals after successful form submission
$effect(() => {
if (form?.success) {
showCreateModal = false;
showDeleteModal = false;
}
});
</script>
{#if form?.message}
<Toast message={form.message} type={form.success ? 'success' : 'error'} />
{/if}
<div class="min-h-screen bg-gray-900 text-white">
<header class="bg-gray-800 shadow-md">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center space-x-4">
<a href="/dashboard" class="text-gray-300 hover:text-white" aria-label="Back to dashboard">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
</a>
<h1 class="text-2xl font-bold">User Management</h1>
</div>
{#if hasPermission($user, 'membership.create')}
<button
onclick={openCreateModal}
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Create User
</button>
{/if}
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Filters -->
<div class="mb-6 rounded-lg border border-gray-700 bg-gray-800 p-4">
<h2 class="mb-3 text-lg font-semibold">Filters</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label for="username" class="block text-sm font-medium text-gray-300">Username</label>
<input
id="username"
type="text"
bind:value={usernameFilter}
placeholder="Search by username..."
class="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 for="role" class="block text-sm font-medium text-gray-300">Role</label>
<input
id="role"
type="text"
bind:value={roleNameFilter}
placeholder="Filter by role..."
class="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 class="mt-4 flex space-x-2">
<button
onclick={applyFilters}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium hover:bg-blue-700"
>
Apply Filters
</button>
<button
onclick={resetFilters}
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
>
Reset
</button>
</div>
</div>
<!-- Results Summary -->
<div class="mb-4 text-sm text-gray-400">
Showing {users.length} of {pagination.total} users
{#if pagination.total_pages > 1}
(Page {pagination.page} of {pagination.total_pages})
{/if}
</div>
<!-- Users Table -->
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-900">
<tr>
<th class="px-6 py-3 text-left">
<button
class="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase hover:text-white"
onclick={() => handleSort('username')}
>
<span>Username</span>
<span>{getSortIcon('username')}</span>
</button>
</th>
<th class="px-6 py-3 text-left">
<button
class="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase hover:text-white"
onclick={() => handleSort('role')}
>
<span>Role</span>
<span>{getSortIcon('role')}</span>
</button>
</th>
{#if hasPermission($user, 'membership.edit')}
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>
Actions
</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-700 bg-gray-800">
{#each users as userItem (userItem.id)}
<tr class="hover:bg-gray-700">
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-white">
{userItem.username}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap">
<span
class="inline-flex rounded-full bg-blue-900 px-2 py-1 text-xs font-semibold text-blue-300"
>
{userItem.role.name}
</span>
</td>
{#if hasPermission($user, 'membership.edit')}
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap">
<div class="flex space-x-2">
<button class="text-blue-400 hover:text-blue-300"> Edit </button>
{#if hasPermission($user, 'membership.delete')}
<button
onclick={() => openDeleteModal(userItem)}
class="text-red-400 hover:text-red-300"
>
Delete
</button>
{/if}
</div>
</td>
{/if}
</tr>
{:else}
<tr>
<td colspan="3" class="px-6 py-4 text-center text-sm text-gray-400">
No users found
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if pagination.total_pages > 1}
<div class="mt-6 flex justify-center">
<Pagination
currentPage={pagination.page}
totalPages={pagination.total_pages}
onPageChange={handlePageChange}
/>
</div>
{/if}
</main>
</div>
<!-- Create User Modal -->
{#if showCreateModal}
<div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div class="w-full max-w-md rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold text-white">Create New User</h3>
<form method="POST" action="?/create">
<div class="mb-4">
<label for="create-username" class="block text-sm font-medium text-gray-300"
>Username</label
>
<input
id="create-username"
name="username"
type="text"
bind:value={createForm.username}
required
class="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"
/>
</div>
<div class="mb-4">
<label for="create-password" class="block text-sm font-medium text-gray-300"
>Password</label
>
<input
id="create-password"
name="password"
type="password"
bind:value={createForm.password}
required
class="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"
/>
</div>
<div class="mb-6">
<label for="create-role" class="block text-sm font-medium text-gray-300">Role</label>
<select
id="create-role"
name="role"
bind:value={createForm.role}
required
class="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"
>
<option value="">Select a role...</option>
{#each roles as role}
<option value={role.name}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-2">
<button
type="button"
onclick={closeCreateModal}
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
>
Create User
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && selectedUser}
<div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div class="w-full max-w-md rounded-lg bg-gray-800 p-6">
<h3 class="mb-4 text-lg font-semibold text-white">Delete User</h3>
<p class="mb-6 text-gray-300">
Are you sure you want to delete the user "{selectedUser.username}"? This action cannot be
undone.
</p>
<form method="POST" action="?/delete">
<input type="hidden" name="id" value={selectedUser.id} />
<div class="flex justify-end space-x-2">
<button
type="button"
onclick={closeDeleteModal}
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium hover:bg-red-700"
>
Delete User
</button>
</div>
</form>
</div>
</div>
{/if}
<svelte:head>
<title>User Management - ACC Server Manager</title>
</svelte:head>

View File

@@ -1,97 +0,0 @@
import {
updateConfig,
getConfigFiles,
getServerById,
getStateHistoryStats
} from '$api/serverService';
import type { Actions } from './$types';
import { checkAuth } from '$api/authService';
import { getTracks } from '$api/lookupService';
import { fail, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { configFile, type Config } from '$models/config';
import { set } from 'lodash-es';
import { subDays, formatISO } from 'date-fns';
import { UTCDate } from '@date-fns/utc';
export const load = async (event: RequestEvent) => {
if (!event.params.id) return redirect(308, '/dashboard');
const today = new UTCDate();
const endDate = formatISO(today);
const startDate = formatISO(subDays(today, 30));
const [server, configs, tracks, statistics] = await Promise.all([
getServerById(event, event.params.id),
getConfigFiles(event, event.params.id),
getTracks(event),
getStateHistoryStats(event, event.params.id, startDate, endDate)
]);
return {
id: event.params.id,
configs,
tracks,
server,
statistics
};
};
export const actions = {
update: async (event: RequestEvent) => {
try {
const { id, restart, file, data } = await destructureFormData(event);
await updateConfig(event, id, file, data, true, restart);
return { success: true, message: `Configuration file '${file}' updated successfully.` };
} catch (err) {
const message = err instanceof Error ? err.message : 'An unknown error occurred';
return fail(500, { message: `Failed to update configuration: ${message}` });
}
}
} satisfies Actions;
async function destructureFormData(
event: RequestEvent
): Promise<{ id: string; restart: boolean; data: Config; file: configFile }> {
const formData = await event.request.formData();
const id = formData.get('id');
if (!id || typeof id !== 'string') {
throw new Error('Server ID is missing or invalid.');
}
const file = formData.get('file');
if (!file || typeof file !== 'string') {
throw new Error('Config file name is missing or invalid.');
}
const restart = formData.get('restart');
const object: Record<string, unknown> = {};
formData.forEach((value, key) => {
// Exclude metadata fields from the dynamic object
if (key === 'id' || key === 'restart' || key === 'file') {
return;
}
set(object, key, parseFormField(value));
});
return {
id,
restart: restart === 'on' || restart === 'true',
data: object as unknown as Config,
file: file as configFile
};
}
/**
* Parses a form field value. If the value can be cleanly converted to a number,
* it returns a number; otherwise, it returns the original string.
*/
function parseFormField(value: FormDataEntryValue): string | number {
// Avoid converting empty strings to 0
if (value === '' || typeof value !== 'string') {
return value as string;
}
// Check if string is a valid number without being too aggressive
const num = Number(value);
return !Number.isNaN(num) && value.trim() !== '' ? num : value;
}

View File

@@ -1,201 +0,0 @@
<script lang="ts">
import EditorAssistRules from '$components/EditorAssistRules.svelte';
import EditorConfiguration from '$components/EditorConfiguration.svelte';
import EditorEvent from '$components/EditorEvent.svelte';
import EditorEventRules from '$components/EditorEventRules.svelte';
import EditorSettings from '$components/EditorSettings.svelte';
import Statistics from '$components/Statistics.svelte';
import Toast from '$components/Toast.svelte';
import { getStatusColor, serviceStatusToString } from '$lib/types/serviceStatus.js';
import { serverTab } from '$models/config.js';
let { data, form } = $props();
const configs = data.configs;
const tracks = data.tracks;
const id = data.id;
const server = data.server;
const statistics = data.statistics;
let tab = $state(serverTab.statistics);
</script>
{#if form?.message}
<Toast message={form.message} type={form.success ? 'success' : 'error'} />
{/if}
<svelte:head>
<title>{server.name}</title>
</svelte:head>
<div class="flex min-h-screen bg-gray-900 text-white">
<!-- Sidebar -->
<aside class="w-64 flex-shrink-0 border-r border-gray-700 bg-gray-800">
<!-- Update the sidebar header to make the server name clickable -->
<div class="border-b border-gray-700 p-4">
<a href="/dashboard" class="transition-colors hover:text-green-400">
<h2 class="truncate text-lg font-semibold">{server.name}</h2>
</a>
<div class="mt-1 flex items-center">
<span class={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}></span>
<span class="text-sm capitalize">{serviceStatusToString(server.status)}</span>
</div>
</div>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.statistics ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.statistics}
onclick={() => (tab = serverTab.statistics)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Statistics
</button>
</li>
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.event ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.event || !configs.event}
onclick={() => (tab = serverTab.event)}
>
Event
</button>
</li>
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.configuration ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.configuration || !configs.configuration}
onclick={() => (tab = serverTab.configuration)}
>
Configuration
</button>
</li>
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.settings ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.settings || !configs.settings}
onclick={() => (tab = serverTab.settings)}
>
Settings
</button>
</li>
{#if configs.assistRules}
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.assistRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.assistRules || !configs.assistRules}
onclick={() => (tab = serverTab.assistRules)}
>
Assist Rules
</button>
</li>
{/if}
{#if configs.eventRules}
<li>
<button
class={`w-full rounded-md px-3 py-2 text-left ${tab === serverTab.eventRules ? 'bg-green-600 text-white' : 'text-gray-300 hover:bg-gray-700'}`}
disabled={tab === serverTab.eventRules || !configs.eventRules}
onclick={() => (tab = serverTab.eventRules)}
>
Event Rules
</button>
</li>
{/if}
</ul>
</nav>
</aside>
<main class="flex-1 overflow-auto">
<header class="flex items-center justify-between bg-gray-800 p-4 shadow-md">
<h1 class="text-xl font-semibold">
{#if tab === serverTab.statistics}
Statistics
{:else if tab === serverTab.event}
Event
{:else if tab === serverTab.configuration}
Configuration
{:else if tab === serverTab.settings}
Settings
{:else if tab === serverTab.assistRules}
Assist Rules
{:else if tab === serverTab.eventRules}
Event Rules
{/if}
</h1>
<div class="flex space-x-3">
<a
href="/dashboard"
class="flex items-center rounded-md bg-gray-700 px-3 py-1.5 text-sm hover:bg-gray-600"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to Dashboard
</a>
<a
href="/logout"
class="flex items-center rounded-md bg-red-700 px-3 py-1.5 text-sm hover:bg-red-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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>
Logout
</a>
</div>
</header>
<div class="p-6">
{#if tab === serverTab.statistics}
<Statistics {statistics} />
{:else if tab === serverTab.event}
<EditorEvent config={configs.event} {tracks} {id} />
{:else if tab === serverTab.configuration}
<EditorConfiguration config={configs.configuration} {id} />
{:else if tab === serverTab.settings}
<EditorSettings config={configs.settings} {id} />
{:else if tab === serverTab.assistRules}
<EditorAssistRules config={configs.assistRules} {id} />
{:else if tab === serverTab.eventRules}
<EditorEventRules config={configs.eventRules} {id} />
{:else}
<div class="rounded-lg bg-gray-800 p-6 text-center">
<p class="text-gray-400">Select a section from the sidebar to edit settings</p>
</div>
{/if}
</div>
</main>
</div>

View File

@@ -1,19 +0,0 @@
import { login } from '$api/authService';
import { authStore } from '$stores/authStore';
import type { RequestEvent } from '@sveltejs/kit';
import type { Actions } from './$types';
import { redirect } from '@sveltejs/kit';
export const actions = {
login: async (event: RequestEvent) => {
const formData = await event.request.formData();
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
authStore.set({ error: 'Invalid username or password' });
return;
}
const isAuth = await login(event, username, password);
if (isAuth) redirect(303, '/dashboard');
}
} satisfies Actions;

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { authStore } from '$stores/authStore';
import { get } from 'svelte/store';
let username = $state('');
let password = $state('');
let formLoading = $state(false);
let { error } = get(authStore);
</script>
<svelte:head>
<title>ACC Server Manager - Login</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-900">
<div class="w-full max-w-md p-8 space-y-8 bg-gray-800 rounded-lg shadow-lg">
<div class="text-center">
<h1 class="text-3xl font-bold text-white">ACC Server Manager</h1>
<p class="mt-2 text-gray-400">Sign in to manage your servers</p>
</div>
{#if error}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-md text-red-200 text-sm">
{error}
</div>
{/if}
<form
method="POST"
action="?/login"
use:enhance={() => {
formLoading = true;
}}
>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-300">Username</label>
<input
type="text"
name="username"
id="username"
autocomplete="username"
required
bind:value={username}
disabled={formLoading}
class="mt-1 block w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300">Password</label>
<input
type="password"
name="password"
id="password"
autocomplete="current-password"
required
bind:value={password}
disabled={formLoading}
class="mt-1 block w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={formLoading}
class="w-full flex justify-center mt-4 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if formLoading}
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
{:else}
Sign in
{/if}
</button>
</div>
</form>
</div>
</div>
<style>
.error {
color: red;
}
</style>

View File

@@ -1,8 +0,0 @@
import { logout } from '$api/authService';
import type { RequestEvent } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
export const load = async (event: RequestEvent) => {
await logout(event);
redirect(303, '/login');
};

View File

@@ -1,13 +0,0 @@
import { writable } from 'svelte/store';
export const authStore = writable<{
username?: string;
password?: string;
token?: string;
error?: string;
}>({
username: undefined,
password: undefined,
token: undefined,
error: undefined
});

View File

@@ -1,11 +0,0 @@
import { IoRedisSessionStore } from '@ethercorps/sveltekit-redis-session';
import Redis from 'ioredis';
import { env } from '$env/dynamic/private';
// Now we will create new Instance for RedisSessionStore
const options = {
redisClient: new Redis(env.REDIS_URL, { lazyConnect: true }),
secret: env.SECRET
};
// These are the required options to use RedisSessionStore.
export const redisSessionManager = new IoRedisSessionStore(options);

View File

@@ -1,24 +0,0 @@
.btn {
@apply px-4 py-1 font-bold;
}
.btn-sidebar {
@apply flex w-full items-center p-2 dark:text-white dark:hover:bg-gray-600;
}
.btn-blue {
@apply rounded bg-blue-500 text-white;
}
.btn-login {
@apply flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600;
}
.btn:hover {
@apply cursor-pointer;
}
.btn:disabled {
@apply cursor-default opacity-60;
}
.btn-blue:hover {
@apply bg-blue-700;
}
.btn-sidebar:disabled {
@apply bg-gray-600;
}

View File

@@ -1,27 +0,0 @@
.form-textarea {
@apply block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6;
}
.form {
@apply block;
}
.form-input {
@apply w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent;
}
.form-select {
@apply w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent;
}
.form:disabled {
@apply opacity-60;
}
.input-block {
@apply flex items-center rounded-md bg-white pl-3 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600;
}
.form-login {
@apply block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6;
}

View File

@@ -1,43 +0,0 @@
/* HTML: <div class="loader"></div> */
.loader {
width: 40px;
height: 40px;
--c: no-repeat linear-gradient(orange 0 0);
background: var(--c), var(--c), var(--c), var(--c);
background-size: 21px 21px;
animation: l5 1.5s infinite cubic-bezier(0.3, 1, 0, 1);
}
@keyframes l5 {
0% {
background-position:
0 0,
100% 0,
100% 100%,
0 100%;
}
33% {
background-position:
0 0,
100% 0,
100% 100%,
0 100%;
width: 60px;
height: 60px;
}
66% {
background-position:
100% 0,
100% 100%,
0 100%,
0 0;
width: 60px;
height: 60px;
}
100% {
background-position:
100% 0,
100% 100%,
0 100%,
0 0;
}
}