migrate to nextjs
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import './styles/button.css';
|
||||
@import './styles/loader.css';
|
||||
@import './styles/inputs.css';
|
||||
15
src/app.d.ts
vendored
15
src/app.d.ts
vendored
@@ -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 {};
|
||||
12
src/app.html
12
src/app.html
@@ -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>
|
||||
38
src/app/dashboard/membership/page.tsx
Normal file
38
src/app/dashboard/membership/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/dashboard/page.tsx
Normal file
78
src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/app/dashboard/server/[id]/page.tsx
Normal file
43
src/app/dashboard/server/[id]/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
11
src/app/globals.css
Normal file
11
src/app/globals.css
Normal 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
22
src/app/layout.tsx
Normal 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
51
src/app/login/page.tsx
Normal 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
12
src/app/page.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
148
src/components/configuration/AssistRulesEditor.tsx
Normal file
148
src/components/configuration/AssistRulesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/configuration/ConfigurationEditor.tsx
Normal file
132
src/components/configuration/ConfigurationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
src/components/configuration/EventConfigEditor.tsx
Normal file
426
src/components/configuration/EventConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/configuration/EventRulesEditor.tsx
Normal file
172
src/components/configuration/EventRulesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/components/configuration/ServerSettingsEditor.tsx
Normal file
247
src/components/configuration/ServerSettingsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/membership/CreateUserModal.tsx
Normal file
128
src/components/membership/CreateUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/membership/DeleteUserModal.tsx
Normal file
75
src/components/membership/DeleteUserModal.tsx
Normal 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 "{user.username}"? 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>
|
||||
);
|
||||
}
|
||||
246
src/components/membership/UserManagementTable.tsx
Normal file
246
src/components/membership/UserManagementTable.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/components/providers/QueryProvider.tsx
Normal file
13
src/components/providers/QueryProvider.tsx
Normal 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>;
|
||||
}
|
||||
84
src/components/server/ServerCard.tsx
Normal file
84
src/components/server/ServerCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/server/ServerConfigurationTabs.tsx
Normal file
92
src/components/server/ServerConfigurationTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/server/ServerHeader.tsx
Normal file
109
src/components/server/ServerHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/statistics/DailyActivityChart.tsx
Normal file
77
src/components/statistics/DailyActivityChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/statistics/PlayerCountChart.tsx
Normal file
95
src/components/statistics/PlayerCountChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/statistics/RecentSessions.tsx
Normal file
85
src/components/statistics/RecentSessions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/statistics/SessionTypesChart.tsx
Normal file
67
src/components/statistics/SessionTypesChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/statistics/StatCard.tsx
Normal file
21
src/components/statistics/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/statistics/StatisticsDashboard.tsx
Normal file
67
src/components/statistics/StatisticsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal 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';
|
||||
29
src/components/ui/Input.tsx
Normal file
29
src/components/ui/Input.tsx
Normal 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';
|
||||
24
src/components/ui/LoadingSpinner.tsx
Normal file
24
src/components/ui/LoadingSpinner.tsx
Normal 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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/Modal.tsx
Normal file
54
src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/ui/Pagination.tsx
Normal file
73
src/components/ui/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/ui/RefreshButton.tsx
Normal file
12
src/components/ui/RefreshButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/Select.tsx
Normal file
36
src/components/ui/Select.tsx
Normal 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';
|
||||
49
src/components/ui/Toast.tsx
Normal file
49
src/components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
33
src/lib/actions/auth.ts
Normal 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');
|
||||
}
|
||||
197
src/lib/actions/configuration.ts
Normal file
197
src/lib/actions/configuration.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
41
src/lib/actions/membership.ts
Normal file
41
src/lib/actions/membership.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
38
src/lib/actions/servers.ts
Normal file
38
src/lib/actions/servers.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
73
src/lib/api/client/hooks.ts
Normal file
73
src/lib/api/client/hooks.ts
Normal 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'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
12
src/lib/api/client/query-client.ts
Normal file
12
src/lib/api/client/query-client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
30
src/lib/api/server/auth.ts
Normal file
30
src/lib/api/server/auth.ts
Normal 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);
|
||||
}
|
||||
31
src/lib/api/server/base.ts
Normal file
31
src/lib/api/server/base.ts
Normal 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;
|
||||
}
|
||||
40
src/lib/api/server/configuration.ts
Normal file
40
src/lib/api/server/configuration.ts
Normal 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
|
||||
});
|
||||
}
|
||||
24
src/lib/api/server/lookups.ts
Normal file
24
src/lib/api/server/lookups.ts
Normal 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);
|
||||
}
|
||||
51
src/lib/api/server/membership.ts
Normal file
51
src/lib/api/server/membership.ts
Normal 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);
|
||||
}
|
||||
30
src/lib/api/server/servers.ts
Normal file
30
src/lib/api/server/servers.ts
Normal 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);
|
||||
}
|
||||
16
src/lib/api/server/statistics.ts
Normal file
16
src/lib/api/server/statistics.ts
Normal 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
31
src/lib/auth/server.ts
Normal 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();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
17
src/lib/session/config.ts
Normal file
17
src/lib/session/config.ts
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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
6
src/lib/types/index.ts
Normal 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';
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
43
src/lib/types/statistics.ts
Normal file
43
src/lib/types/statistics.ts
Normal 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[];
|
||||
}
|
||||
@@ -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
6
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user