30 Commits

Author SHA1 Message Date
Fran Jurmanović
3ba83ad538 update websocket url passing through server 2025-09-18 23:14:45 +02:00
Fran Jurmanović
fe6a36f3dc move websocket base url inside of constructor 2025-09-18 22:56:47 +02:00
Fran Jurmanović
1ca98233f8 fix build errors 2025-09-18 22:12:40 +02:00
Fran Jurmanović
fe4d299eae code cleanup 2025-09-18 22:04:34 +02:00
Fran Jurmanović
b0ee67c2be server creation 2025-09-18 01:34:16 +02:00
Fran Jurmanović
b7999b02e8 fix ApiResponse on success 2025-09-14 21:44:54 +02:00
Fran Jurmanović
4bc74f26d1 update logout method 2025-09-14 21:27:41 +02:00
Fran Jurmanović
e6b7ec7401 fix unbuildable client 2025-09-14 19:08:34 +02:00
Fran Jurmanović
373adcb49d update api fetch client 2025-09-14 19:04:24 +02:00
Fran Jurmanović
8a5afee0e3 logout if unauthorized 2025-09-14 17:51:43 +02:00
Fran Jurmanović
4888db7f1a update layout 2025-08-27 22:11:22 +02:00
Fran Jurmanović
4db5d49a64 update version 2025-08-27 22:11:15 +02:00
Fran Jurmanović
bb0a5ab66d fix form actions 2025-08-27 21:52:35 +02:00
Fran Jurmanović
76d08df3da update version 2025-08-27 21:43:29 +02:00
Fran Jurmanović
fac61ef678 resolve server actions 2025-08-27 21:43:03 +02:00
Fran Jurmanović
55e0370004 update login error message 2025-08-27 21:33:28 +02:00
Fran Jurmanović
9ce5802dc6 update output 2025-08-27 20:49:01 +02:00
Fran Jurmanović
bc0964c758 update version 2025-08-27 20:38:57 +02:00
Fran Jurmanović
b269144ee7 migrate to nextjs 2025-08-27 20:38:19 +02:00
Fran Jurmanović
965e13a0bf update version 2025-07-31 18:14:50 +02:00
Fran Jurmanović
a42e995fd0 update version 2025-07-31 17:34:30 +02:00
Fran Jurmanović
8442aaff8a update service control endpoints 2025-07-29 20:51:28 +02:00
Fran Jurmanović
0472c5e90b remove another console log 2025-07-01 21:45:39 +02:00
Fran Jurmanović
8ccb8033d1 remove console log 2025-07-01 21:44:16 +02:00
Fran Jurmanović
7ae883411b speed up the load times 2025-07-01 21:41:24 +02:00
Fran Jurmanović
995b3e6a63 membership page 2025-06-30 22:51:15 +02:00
Fran Jurmanović
31bed1e6cb fix user data fetch 2025-06-26 01:56:28 +02:00
Fran Jurmanović
a154a4953e fix typo 2025-06-26 00:57:38 +02:00
Fran Jurmanović
283de4f27c fix errors 2025-06-26 00:54:28 +02:00
Fran Jurmanović
53c023ca4d add membership and permissions 2025-06-26 00:52:10 +02:00
127 changed files with 12469 additions and 7548 deletions

52
.gitignore vendored
View File

@@ -1,23 +1,41 @@
node_modules
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# OS
# misc
.DS_Store
Thumbs.db
*.pem
# Env
.env
.env.*
!.env.example
!.env.test
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
.npmrc
View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -3,13 +3,5 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
"plugins": ["prettier-plugin-tailwindcss"],
}

View File

@@ -1,17 +1,35 @@
FROM node:22-alpine AS builder
FROM node:22-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
FROM node:22-alpine
FROM base AS builder
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]
COPY --from=deps /app/node_modules ./node_modules/
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 FJurmanovic
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,38 +1,36 @@
# sv
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Getting Started
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
First, run the development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
## Building
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
To create a production version of your app:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
npm run build
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
You can preview the production build with `npm run preview`.
## Learn More
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -1,26 +0,0 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte']
}
);

25
eslint.config.mjs Normal file
View File

@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone'
};
export default nextConfig;

11132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,46 @@
{
"name": "acc-server-manager-web",
"version": "0.20.2",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@ethercorps/sveltekit-redis-session": "^1.3.1",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/postcss": "^4.0.4",
"@tailwindcss/vite": "^4.0.0",
"@types/d3-scale": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"ioredis": "^5.3.2",
"lodash-es": "^4.17.21",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"uuid": "^11.0.5",
"vite": "^6.0.0"
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"lint": "eslint"
},
"dependencies": {
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"chart.js": "^4.4.9",
"@date-fns/utc": "^2.1.1",
"@hookform/resolvers": "^5.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0"
"date-fns-tz": "^3.2.0",
"ioredis": "^5.7.0",
"iron-session": "^8.0.4",
"lodash-es": "^4.17.21",
"next": "15.5.2",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {}
}
};

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,50 +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 (endpoint != '/api' && 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
) {
const {
data: { token }
} = await redisSessionManager.getSession(event.cookies);
return fetchAPI(endpoint, method, body, { Authorization: `Basic ${token}` });
}
export default fetchAPI;

View File

@@ -1,30 +0,0 @@
import fetchAPI, { fetchAPIEvent } from '$api/apiService';
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) => {
const token = btoa(`${username}:${password}`);
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
if (!(await checkAuth(event))) {
{
authStore.set({ token: undefined, error: 'Invalid username or password.' });
return false;
}
}
return true;
};
export const logout = (event: RequestEvent) => {
return redisSessionManager.deleteCookie(event.cookies);
};
export const checkAuth = async (event: RequestEvent) => {
try {
await fetchAPIEvent(event, '/api');
return true;
} catch (err) {
return false;
}
};

View File

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

View File

@@ -1,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: number) => {
return fetchAPIEvent(event, '/api/restart', 'POST', { serverId });
};
export const startService = async (event: RequestEvent, serverId: number) => {
return fetchAPIEvent(event, '/api/start', 'POST', { serverId });
};
export const stopService = async (event: RequestEvent, serverId: number) => {
return fetchAPIEvent(event, '/api/stop', 'POST', { serverId });
};
export const getServiceStatus = async (event: RequestEvent, serviceName: string) => {
return fetchAPIEvent(event, `/api/${serviceName}`);
};

View File

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

13
src/app.d.ts vendored
View File

@@ -1,13 +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 {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
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';
import { ServerListWithActions } from '@/components/server/ServerListWithActions';
import { WebSocketInitializer } from '@/components/websocket/WebSocketInitializer';
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
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">
<WebSocketInitializer openToken={session.openToken} />
<header className="bg-gray-800 shadow-md">
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-bold">ACC Server Manager</h1>
<div className="flex items-center space-x-4">
{hasPermission(session.user!, 'membership.view') && (
<Link
href="/dashboard/membership"
className="flex items-center text-gray-300 hover:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M15 21a6 6 0 00-9-5.197m0 0A5.975 5.975 0 0112 13a5.975 5.975 0 01-3-1.197"
/>
</svg>
<span className="ml-1 hidden sm:inline">Users</span>
</Link>
)}
<form action={logoutAction}>
<button type="submit" className="flex items-center text-gray-300 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span className="ml-1 hidden sm:inline">Logout</span>
</button>
</form>
</div>
</div>
</header>
<SteamCMDNotification />
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8">
<ServerListWithActions servers={servers} user={session.user!} />
</main>
</div>
);
}

View File

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

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

@@ -0,0 +1,11 @@
@import "tailwindcss";
@layer components {
.form-input {
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500;
}
.form-select {
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500;
}
}

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

@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import './globals.css';
import { QueryProvider } from '@/components/providers/QueryProvider';
import { WebSocketProvider } from '@/lib/websocket/context';
import { SteamCMDProvider } from '@/lib/context/SteamCMDContext';
import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer';
export const metadata: Metadata = {
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>
<WebSocketProvider
websocketURL={process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'ws://localhost:3000/ws'}
>
<SteamCMDProvider>
<ServerCreationPopupProvider>
{children}
<ServerCreationPopupContainer />
</ServerCreationPopupProvider>
</SteamCMDProvider>
</WebSocketProvider>
</QueryProvider>
</body>
</html>
);
}

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

@@ -0,0 +1,65 @@
'use client';
import { loginAction, LoginResult } from '@/lib/actions/auth';
import { useActionState } from 'react';
const initialState: LoginResult = {
message: '',
success: true
};
export default function LoginPage() {
const [state, formAction] = useActionState(loginAction, initialState);
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>
{state?.success ? null : (
<div className="rounded-md border border-red-700 bg-red-900/50 p-3 text-sm text-red-200">
{state?.message}
</div>
)}
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="username" className="mb-2 block text-sm font-medium text-gray-300">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="form-input w-full"
/>
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-gray-300">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="form-input w-full"
/>
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-3 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-none"
>
Sign in
</button>
</form>
</div>
</div>
);
}

7
src/app/logout/route.ts Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import type { AssistRules } from '@/lib/types/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps {
serverId: string;
config: AssistRules;
}
export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) {
const [formData, setFormData] = useState<AssistRules>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateAssistRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update assist rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof AssistRules, value: string | number) => {
setFormData((prev) => ({
...prev,
[key]: typeof value === 'string' ? parseInt(value) : value
}));
};
const assistFields = [
{
key: 'stabilityControlLevelMax' as keyof AssistRules,
label: 'Stability Control Level Max',
type: 'number'
},
{
key: 'disableAutosteer' as keyof AssistRules,
label: 'Disable Autosteer',
type: 'select'
},
{
key: 'disableAutoLights' as keyof AssistRules,
label: 'Disable Auto Lights',
type: 'select'
},
{
key: 'disableAutoWiper' as keyof AssistRules,
label: 'Disable Auto Wiper',
type: 'select'
},
{
key: 'disableAutoEngineStart' as keyof AssistRules,
label: 'Disable Auto Engine Start',
type: 'select'
},
{
key: 'disableAutoPitLimiter' as keyof AssistRules,
label: 'Disable Auto Pit Limiter',
type: 'select'
},
{
key: 'disableAutoGear' as keyof AssistRules,
label: 'Disable Auto Gear',
type: 'select'
},
{
key: 'disableAutoClutch' as keyof AssistRules,
label: 'Disable Auto Clutch',
type: 'select'
},
{
key: 'disableIdealLine' as keyof AssistRules,
label: 'Disable Ideal Line',
type: 'select'
}
];
return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
{type === 'number' ? (
<input
type="number"
disabled={isSubmitting}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
min="0"
max="5"
/>
) : (
<select
disabled={isSubmitting}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
>
<option value={0}>Allowed</option>
<option value={1}>Disabled</option>
</select>
)}
</div>
))}
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
);
}

View File

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

View File

@@ -0,0 +1,426 @@
'use client';
import { useState } from 'react';
import type { EventConfig, Session } from '@/lib/types/config';
import { updateEventConfigAction } from '@/lib/actions/configuration';
interface EventConfigEditorProps {
serverId: string;
config: EventConfig;
}
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) {
const [formData, setFormData] = useState<EventConfig>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (key === 'sessions') {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value.toString());
}
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventConfigAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event config:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return;
setFormData((prev) => ({
...prev,
[key]:
typeof formData[key] === 'number'
? typeof value === 'string'
? parseFloat(value) || 0
: value
: value
}));
};
const handleSessionChange = (index: number, field: keyof Session, value: string | number) => {
const newSessions = [...formData.sessions];
newSessions[index] = {
...newSessions[index],
[field]:
field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value
};
setFormData((prev) => ({
...prev,
sessions: newSessions
}));
};
const addSession = () => {
const newSession: Session = {
hourOfDay: 12,
dayOfWeekend: 1,
timeMultiplier: 1,
sessionType: 'P',
sessionDurationMinutes: 20
};
setFormData((prev) => ({
...prev,
sessions: [...prev.sessions, newSession]
}));
};
const removeSession = (index: number) => {
setFormData((prev) => ({
...prev,
sessions: prev.sessions.filter((_, i) => i !== index)
}));
};
const sessionTypes = [
{ value: 'P', label: 'Practice' },
{ value: 'Q', label: 'Qualifying' },
{ value: 'R', label: 'Race' }
];
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Basic Event Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Track</label>
<select
disabled={isSubmitting}
value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)}
className="form-input w-full"
>
<option value="barcelona">Barcelona</option>
<option value="brands_hatch">Brands Hatch</option>
<option value="hungaroring">Hungaroring</option>
<option value="imola">Imola</option>
<option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option>
<option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option>
<option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option>
<option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option>
<option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Pre-Race Waiting Time (seconds)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Session Over Time (seconds)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Post Qualify Seconds
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Post Race Seconds
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.postRaceSeconds}
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Weather Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Ambient Temperature (°C)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full"
min="0"
max="50"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Cloud Level (0.0-1.0)
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.cloudLevel}
onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
className="form-input w-full"
min="0"
max="1"
step="0.01"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Rain (0.0-1.0)</label>
<input
type="number"
disabled={isSubmitting}
value={formData.rain}
onChange={(e) => handleInputChange('rain', e.target.value)}
className="form-input w-full"
min="0"
max="1"
step="0.01"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Weather Randomness
</label>
<input
type="number"
disabled={isSubmitting}
value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full"
min="0"
max="7"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Simracer Weather Conditions
</label>
<select
disabled={isSubmitting}
value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Fixed Condition Qualification
</label>
<select
disabled={isSubmitting}
value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
</div>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between border-b border-gray-700 pb-2">
<h3 className="text-lg font-medium text-white">Sessions</h3>
<button
type="button"
onClick={addSession}
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Add Session
</button>
</div>
<div className="space-y-4">
{formData.sessions.map((session, index) => (
<div key={index} className="rounded-lg bg-gray-800 p-4">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-medium text-white">Session {index + 1}</h4>
<button
type="button"
onClick={() => removeSession(index)}
disabled={isSubmitting}
className="rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Session Type
</label>
<select
disabled={isSubmitting}
value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-input w-full text-sm"
>
{sessionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Hour of Day
</label>
<input
type="number"
disabled={isSubmitting}
value={session.hourOfDay}
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
className="form-input w-full text-sm"
min="0"
max="23"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Day of Weekend
</label>
<input
type="number"
disabled={isSubmitting}
value={session.dayOfWeekend}
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
className="form-input w-full text-sm"
min="1"
max="3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Time Multiplier
</label>
<input
type="number"
disabled={isSubmitting}
value={session.timeMultiplier}
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
className="form-input w-full text-sm"
min="1"
max="24"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-400">
Duration (minutes)
</label>
<input
type="number"
disabled={isSubmitting}
value={session.sessionDurationMinutes}
onChange={(e) =>
handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
}
className="form-input w-full text-sm"
min="1"
/>
</div>
</div>
</div>
))}
</div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
);
}

View File

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

View File

@@ -0,0 +1,247 @@
'use client';
import { useState } from 'react';
import type { ServerSettings } from '@/lib/types/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps {
serverId: string;
config: ServerSettings;
}
export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) {
const [formData, setFormData] = useState<ServerSettings>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateServerSettingsAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update server settings:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
setFormData((prev) => ({
...prev,
[key]: value
}));
};
const textFields = [
{
key: 'serverName' as keyof ServerSettings,
label: 'Server Name',
type: 'text'
},
{
key: 'adminPassword' as keyof ServerSettings,
label: 'Admin Password',
type: 'password'
},
{
key: 'password' as keyof ServerSettings,
label: 'Password',
type: 'password'
},
{
key: 'spectatorPassword' as keyof ServerSettings,
label: 'Spectator Password',
type: 'password'
},
{
key: 'centralEntryListPath' as keyof ServerSettings,
label: 'Central Entry List Path',
type: 'text'
}
];
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
const numberFields = [
{
key: 'trackMedalsRequirement' as keyof ServerSettings,
label: 'Track Medals Requirement',
min: -1,
max: 3
},
{
key: 'safetyRatingRequirement' as keyof ServerSettings,
label: 'Safety Rating Requirement',
min: -1,
max: 99
},
{
key: 'racecraftRatingRequirement' as keyof ServerSettings,
label: 'Racecraft Rating Requirement',
min: -1,
max: 99
},
{
key: 'maxCarSlots' as keyof ServerSettings,
label: 'Max Car Slots',
min: 1,
max: 30
}
];
const selectFields = [
{
key: 'dumpLeaderboards' as keyof ServerSettings,
label: 'Dump Leaderboards'
},
{ key: 'isRaceLocked' as keyof ServerSettings, label: 'Race Locked' },
{
key: 'randomizeTrackWhenEmpty' as keyof ServerSettings,
label: 'Randomize Track When Empty'
},
{ key: 'allowAutoDQ' as keyof ServerSettings, label: 'Allow Auto DQ' },
{
key: 'shortFormationLap' as keyof ServerSettings,
label: 'Short Formation Lap'
},
{
key: 'ignorePrematureDisconnects' as keyof ServerSettings,
label: 'Ignore Premature Disconnects'
}
];
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Basic Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{textFields.map(({ key, label, type }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<input
type={type}
disabled={isSubmitting}
value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
/>
</div>
))}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Car Group</label>
<select
disabled={isSubmitting}
value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-input w-full"
>
{carGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Requirements & Limits
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{numberFields.map(({ key, label, min, max }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<input
type="number"
disabled={isSubmitting}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full"
min={min}
max={max}
/>
</div>
))}
</div>
</div>
<div className="space-y-6">
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
Race Options
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{selectFields.map(({ key, label }) => (
<div key={key}>
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
<select
disabled={isSubmitting}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-input w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
))}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">
Formation Lap Type
</label>
<select
disabled={isSubmitting}
value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-input w-full"
>
<option value={0}>Old Limiter Lap</option>
<option value={1}>
Free (replaces /manual start), only usable for private servers
</option>
<option value={3}>Default formation lap with position control and UI</option>
</select>
</div>
</div>
</div>
<div className="border-t border-gray-700 pt-6">
<label className="flex items-center">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
/>
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import type { Role } from '@/lib/types';
import { createUserAction } from '@/lib/actions/membership';
interface CreateUserModalProps {
roles: Role[];
onClose: () => void;
}
export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
const [formData, setFormData] = useState({
username: '',
password: '',
role: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formDataObj = new FormData();
formDataObj.append('username', formData.username);
formDataObj.append('password', formData.password);
formDataObj.append('role', formData.role);
try {
const result = await createUserAction(formDataObj);
if (result.success) {
onClose();
window.location.reload();
} else {
setError(result.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">Create New User</h3>
{error && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="create-username" className="block text-sm font-medium text-gray-300">
Username
</label>
<input
id="create-username"
type="text"
value={formData.username}
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
required
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
/>
</div>
<div className="mb-4">
<label htmlFor="create-password" className="block text-sm font-medium text-gray-300">
Password
</label>
<input
id="create-password"
type="password"
value={formData.password}
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
required
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
/>
</div>
<div className="mb-6">
<label htmlFor="create-role" className="block text-sm font-medium text-gray-300">
Role
</label>
<select
id="create-role"
value={formData.role}
onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))}
required
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
>
<option value="">Select a role...</option>
{roles.map((role) => (
<option key={role.id} value={role.name}>
{role.name}
</option>
))}
</select>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
'use client';
import { useState, useEffect, useActionState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management';
import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext';
interface CreateServerModalProps {
isOpen: boolean;
onClose: () => void;
}
const initialState: ServerActionResult = { success: false, message: '', data: undefined };
export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) {
const [serverName, setServerName] = useState('');
const [submittedName, setSubmittedName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPending, setTransition] = useTransition();
const [state, formAction] = useActionState(createServerAction, initialState);
const { showPopup } = useServerCreationPopup();
useEffect(() => {
if (state.success && state.data?.id) {
showPopup(state.data.id, submittedName);
onClose();
setIsSubmitting(false);
}
}, [state.success, state.data, showPopup, onClose, submittedName]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) =>
setTransition(async () => {
e.preventDefault();
if (!serverName.trim()) {
return;
}
setIsSubmitting(true);
const formData = new FormData();
formData.append('name', serverName.trim());
formAction(formData);
setSubmittedName(serverName.trim());
setServerName('');
});
const handleClose = () => {
if (isSubmitting) {
return;
}
onClose();
setServerName('');
setIsSubmitting(false);
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Create New Server">
{!state.success && state.message && (
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{state.message}</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label htmlFor="server-name" className="block text-sm font-medium text-gray-300">
Server Name
</label>
<input
id="server-name"
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
required
disabled={isSubmitting || isPending}
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
placeholder="Enter server name..."
/>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting || isPending}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !serverName.trim() || isPending}
className="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
>
{isSubmitting ? (
<>
<LoadingSpinner className="mr-2 h-4 w-4" />
Creating...
</>
) : (
'Create Server'
)}
</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { deleteServerAction } from '@/lib/actions/server-management';
import { Server } from '@/lib/types/server';
interface DeleteServerModalProps {
isOpen: boolean;
onClose: () => void;
server: Server;
}
export function DeleteServerModal({ isOpen, onClose, server }: DeleteServerModalProps) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
setError(null);
startTransition(async () => {
try {
const result = await deleteServerAction(server.id);
if (result.success) {
onClose();
} else {
setError(result.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
});
};
const handleClose = () => {
if (isPending) {
return;
}
onClose();
setError(null);
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Delete Server">
{error && <div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>}
<div className="mb-6">
<p className="text-gray-300">
Are you sure you want to delete the server <strong>&quot;{server.name}&quot;</strong>?
</p>
<p className="mt-2 text-sm text-gray-400">This action cannot be undone.</p>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={handleClose}
disabled={isPending}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={isPending}
className="flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-red-700 disabled:opacity-50"
>
{isPending ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
Deleting...
</>
) : (
'Delete Server'
)}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,151 @@
import Link from 'next/link';
import { useState, useTransition } from 'react';
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/types';
import { User, hasPermission } from '@/lib/types/user';
import {
startServerEventAction,
restartServerEventAction,
stopServerEventAction
} from '@/lib/actions/servers';
import { DeleteServerModal } from './DeleteServerModal';
import { useRouter } from 'next/navigation';
interface ServerCardProps {
server: Server;
user: User;
}
export function ServerCard({ server, user }: ServerCardProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const canDeleteServer = hasPermission(user, 'server.delete');
const startServer = () =>
startTransition(async () => {
await startServerEventAction(server.id);
router.refresh();
});
const restartServer = () =>
startTransition(async () => {
await restartServerEventAction(server.id);
router.refresh();
});
const stopServer = () =>
startTransition(async () => {
await stopServerEventAction(server.id);
router.refresh();
});
const disabled = [
ServiceStatus.Restarting,
ServiceStatus.Starting,
ServiceStatus.Stopping,
ServiceStatus.Unknown
].includes(server.status);
return (
<>
<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="flex items-center space-x-2">
{canDeleteServer && (
<button
onClick={(e) => {
e.preventDefault();
setIsDeleteModalOpen(true);
}}
className="text-gray-400 hover:text-red-400"
title="Delete Server"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9zM4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 102 0v3a1 1 0 11-2 0V9zm4 0a1 1 0 10-2 0v3a1 1 0 002 0V9z"
clipRule="evenodd"
/>
</svg>
</button>
)}
<div className="text-gray-400 hover:text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 text-sm text-gray-300">
<div>
<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">
<button
type="button"
onClick={startServer}
disabled={server.status === ServiceStatus.Running || isPending || disabled}
className="rounded bg-green-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Start
</button>
<button
type="button"
onClick={restartServer}
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
className="rounded bg-yellow-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Restart
</button>
<button
type="button"
onClick={stopServer}
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
className="rounded bg-red-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Stop
</button>
</div>
</div>
<DeleteServerModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
server={server}
/>
</>
);
}

View File

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

View File

@@ -0,0 +1,477 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context';
import {
WebSocketMessage,
StepData,
SteamOutputData,
ErrorData,
CompleteData
} from '@/lib/websocket/client';
interface ServerCreationPopupProps {
serverId: string;
serverName: string;
isOpen: boolean;
onClose: () => void;
onComplete?: (success: boolean, message: string) => void;
}
interface ConsoleEntry {
id: string;
timestamp: number;
type: 'step' | 'steam_output' | 'error' | 'complete';
content: string;
level: 'info' | 'success' | 'warning' | 'error';
}
interface StepStatus {
step: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
message: string;
}
const STEPS = [
{ key: 'validation', label: 'Validation' },
{ key: 'directory_creation', label: 'Directory Creation' },
{ key: 'steam_download', label: 'Steam Download' },
{ key: 'config_generation', label: 'Config Generation' },
{ key: 'service_creation', label: 'Service Creation' },
{ key: 'firewall_rules', label: 'Firewall Rules' },
{ key: 'database_save', label: 'Database Save' },
{ key: 'completed', label: 'Completed' }
];
export function ServerCreationPopup({
serverId,
serverName,
isOpen,
onClose,
onComplete
}: ServerCreationPopupProps) {
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
const [steps, setSteps] = useState<Record<string, StepStatus>>({});
const [isCompleted, setIsCompleted] = useState(false);
const [completionResult, setCompletionResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
const [isConsoleVisible, setIsConsoleVisible] = useState(true);
const {
associateWithServer,
addMessageHandler,
removeMessageHandler,
connectionStatus,
reconnect
} = useWebSocket();
const consoleRef = useRef<HTMLDivElement>(null);
const addEntry = useCallback((entry: Omit<ConsoleEntry, 'id'>) => {
const newEntry = {
...entry,
id: `${Date.now()}-${Math.random()}`
};
setEntries((prev) => [...prev, newEntry]);
}, []);
const scrollToBottom = () => {
if (consoleRef.current && !isMinimized && isConsoleVisible) {
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
}
};
useEffect(() => {
scrollToBottom();
}, [entries, isMinimized, isConsoleVisible]);
useEffect(() => {
if (serverId && isOpen) {
associateWithServer(serverId);
}
}, [serverId, isOpen, associateWithServer]);
const handleMessage = useCallback(
(message: WebSocketMessage) => {
if (message.server_id !== serverId) return;
const timestamp = message.timestamp;
switch (message.type) {
case 'step': {
const data = message.data as StepData;
setSteps((prev) => {
const updatedSteps = { ...prev };
const stepIndex = STEPS.findIndex((step) => step.key === data.step);
if (stepIndex > 0) {
for (let i = 0; i < stepIndex; i++) {
const prevStepKey = STEPS[i].key;
if (!updatedSteps[prevStepKey] || updatedSteps[prevStepKey].status === 'pending') {
updatedSteps[prevStepKey] = {
step: prevStepKey,
status: 'completed',
message: `${prevStepKey.replace('_', ' ')} completed`
};
}
}
}
updatedSteps[data.step] = {
step: data.step,
status: data.status,
message: data.message
};
return updatedSteps;
});
let level: ConsoleEntry['level'] = 'info';
if (data.status === 'completed') level = 'success';
else if (data.status === 'failed') level = 'error';
else if (data.status === 'in_progress') level = 'warning';
addEntry({
timestamp,
type: 'step',
content: `[${data.step.toUpperCase()}] ${data.message}${data.error ? ` - ${data.error}` : ''}`,
level
});
break;
}
case 'steam_output': {
const data = message.data as SteamOutputData;
setSteps((prev) => {
const updatedSteps = { ...prev };
if (
!updatedSteps['steam_download'] ||
updatedSteps['steam_download'].status === 'pending'
) {
updatedSteps['steam_download'] = {
step: 'steam_download',
status: 'in_progress',
message: 'Steam download in progress'
};
}
if (!updatedSteps['validation'] || updatedSteps['validation'].status === 'pending') {
updatedSteps['validation'] = {
step: 'validation',
status: 'completed',
message: 'Validation completed'
};
}
if (
!updatedSteps['directory_creation'] ||
updatedSteps['directory_creation'].status === 'pending'
) {
updatedSteps['directory_creation'] = {
step: 'directory_creation',
status: 'completed',
message: 'Directory creation completed'
};
}
return updatedSteps;
});
addEntry({
timestamp,
type: 'steam_output',
content: data.output,
level: data.is_error ? 'error' : 'info'
});
break;
}
case 'error': {
const data = message.data as ErrorData;
setSteps((prev) => {
const updatedSteps = { ...prev };
const currentStep = Object.values(updatedSteps).find(
(step) => step.status === 'in_progress'
);
if (currentStep) {
updatedSteps[currentStep.step] = {
...currentStep,
status: 'failed',
message: `${currentStep.step.replace('_', ' ')} failed: ${data.error}`
};
}
return updatedSteps;
});
addEntry({
timestamp,
type: 'error',
content: `ERROR: ${data.error}${data.details ? ` - ${data.details}` : ''}`,
level: 'error'
});
break;
}
case 'complete': {
const data = message.data as CompleteData;
setIsCompleted(true);
setCompletionResult({ success: data.success, message: data.message });
addEntry({
timestamp,
type: 'complete',
content: `COMPLETED: ${data.message}`,
level: data.success ? 'success' : 'error'
});
onComplete?.(data.success, data.message);
break;
}
}
},
[serverId, addEntry, onComplete]
);
useEffect(() => {
if (isOpen) {
addMessageHandler(handleMessage);
return () => {
removeMessageHandler(handleMessage);
};
}
}, [addMessageHandler, removeMessageHandler, handleMessage, isOpen]);
const handleReconnect = async () => {
try {
await reconnect();
} catch (error) {
console.error('Failed to reconnect:', error);
}
};
const getStepStatusIcon = (status: StepStatus['status']) => {
switch (status) {
case 'pending':
return '⏳';
case 'in_progress':
return '🔄';
case 'completed':
return '✅';
case 'failed':
return '❌';
}
};
const getEntryClassName = (level: ConsoleEntry['level']) => {
switch (level) {
case 'success':
return 'text-green-400';
case 'warning':
return 'text-yellow-400';
case 'error':
return 'text-red-400';
default:
return 'text-gray-300';
}
};
const getConnectionStatusIcon = () => {
switch (connectionStatus) {
case 'connected':
return '🟢';
case 'connecting':
return '🟡';
case 'disconnected':
return '⚫';
case 'error':
return '🔴';
}
};
const getCurrentProgress = () => {
const completedSteps = Object.values(steps).filter(
(step) => step.status === 'completed'
).length;
const totalSteps = STEPS.length;
return { completed: completedSteps, total: totalSteps };
};
if (!isOpen) return null;
if (isMinimized) {
const progress = getCurrentProgress();
const isProgressing =
!isCompleted && Object.values(steps).some((step) => step.status === 'in_progress');
return (
<div className="fixed right-4 bottom-4 z-40">
<button
onClick={() => setIsMinimized(false)}
className={`flex h-16 w-16 items-center justify-center rounded-full border-2 shadow-lg transition-all hover:scale-105 ${
isCompleted
? completionResult?.success
? 'border-green-400 bg-green-600 hover:bg-green-700'
: 'border-red-400 bg-red-600 hover:bg-red-700'
: 'border-blue-400 bg-blue-600 hover:bg-blue-700'
}`}
title={`Server Creation: ${serverName} - ${progress.completed}/${progress.total} steps`}
>
<div className="text-center text-white">
{isCompleted ? (
<span className="text-2xl">{completionResult?.success ? '✅' : '❌'}</span>
) : (
<>
<div className="text-xs font-bold">
{progress.completed}/{progress.total}
</div>
{isProgressing && <div className="animate-pulse text-xs"></div>}
</>
)}
</div>
{!isCompleted && (
<svg className="absolute inset-0 h-16 w-16 -rotate-90 transform">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="none"
className="text-gray-300 opacity-20"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray={`${(progress.completed / progress.total) * 175.929} 175.929`}
className="text-white transition-all duration-500"
/>
</svg>
)}
</button>
</div>
);
}
return (
<div className="fixed right-4 bottom-4 z-40 max-h-[600px] w-96 rounded-lg border border-gray-700 bg-gray-800 shadow-2xl select-none">
<div className="flex items-center justify-between border-b border-gray-700 p-4">
<div className="flex items-center space-x-2">
<span className="text-lg">🔧</span>
<h3 className="truncate font-medium text-white">{serverName}</h3>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<span className="text-sm">{getConnectionStatusIcon()}</span>
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<button
onClick={handleReconnect}
className="rounded bg-blue-600 px-1 py-0.5 text-xs hover:bg-blue-700"
title="Reconnect"
>
🔄
</button>
)}
</div>
<button
onClick={() => setIsConsoleVisible(!isConsoleVisible)}
className="text-sm text-gray-400 hover:text-white"
title={isConsoleVisible ? 'Hide Console' : 'Show Console'}
>
📋
</button>
<button
onClick={() => setIsMinimized(true)}
className="text-gray-400 hover:text-white"
title="Minimize"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<button onClick={onClose} className="text-gray-400 hover:text-white" title="Close">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="border-b border-gray-700 p-4">
<div className="grid grid-cols-2 gap-2">
{STEPS.map(({ key, label }) => {
const stepStatus = steps[key];
return (
<div
key={key}
className={`flex items-center space-x-2 rounded p-2 text-xs ${
stepStatus?.status === 'completed'
? 'bg-green-900/50 text-green-100'
: stepStatus?.status === 'in_progress'
? 'bg-yellow-900/50 text-yellow-100'
: stepStatus?.status === 'failed'
? 'bg-red-900/50 text-red-100'
: 'bg-gray-700 text-gray-300'
}`}
>
<span>{getStepStatusIcon(stepStatus?.status || 'pending')}</span>
<span className="truncate">{label}</span>
</div>
);
})}
</div>
</div>
{isConsoleVisible && (
<div className="h-64 bg-black">
<div ref={consoleRef} className="h-full space-y-1 overflow-y-auto p-3 font-mono text-xs">
{entries.map((entry) => (
<div key={entry.id} className={`${getEntryClassName(entry.level)} leading-tight`}>
<span className="text-xs text-gray-500">
{new Date(entry.timestamp * 1000).toLocaleTimeString()}
</span>{' '}
<span>{entry.content}</span>
</div>
))}
{entries.length === 0 && (
<div className="py-8 text-center text-gray-500">
Waiting for server creation to begin...
</div>
)}
</div>
</div>
)}
{isCompleted && (
<div
className={`p-3 text-center text-sm ${
completionResult?.success
? 'bg-green-900/50 text-green-100'
: 'bg-red-900/50 text-red-100'
}`}
>
{completionResult?.message}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopup } from './ServerCreationPopup';
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
import { useCallback } from 'react';
export function ServerCreationPopupContainer() {
const { popup, hidePopup } = useServerCreationPopup();
const { dissociateServer } = useSteamCMD();
const handleClose = useCallback(() => {
hidePopup();
if (popup) return dissociateServer(popup.serverId);
}, [popup, dissociateServer, hidePopup]);
if (!popup) return null;
const handleComplete = (success: boolean) => {
if (success) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
};
return (
<ServerCreationPopup
serverId={popup.serverId}
serverName={popup.serverName}
isOpen={popup.isOpen}
onClose={handleClose}
onComplete={handleComplete}
/>
);
}

View File

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

View File

@@ -0,0 +1,113 @@
import Link from 'next/link';
import { Server, getStatusColor, serviceStatusToString, ServiceStatus } from '@/lib/types/server';
import {
startServerEventAction,
restartServerEventAction,
stopServerEventAction
} 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={startServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Running}
className="rounded bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Start
</button>
</form>
<form action={restartServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
className="rounded bg-yellow-600 px-4 py-2 font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Restart
</button>
</form>
<form action={stopServerEventAction.bind(null, server.id)}>
<button
type="submit"
disabled={server.status === ServiceStatus.Stopped}
className="rounded bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Stop
</button>
</form>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4">
<div>
<div className="text-sm text-gray-500">Current Track</div>
<div className="text-lg font-medium text-white">{server.state?.track || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">Players</div>
<div className="text-lg font-medium text-white">
{server.state?.playerCount || 0} / {server.state?.maxConnections || 0}
</div>
</div>
<div>
<div className="text-sm text-gray-500">Session</div>
<div className="text-lg font-medium text-white">{server.state?.session || 'N/A'}</div>
</div>
<div>
<div className="text-sm text-gray-500">Max Connections</div>
<div className="text-lg font-medium text-white">
{server.state?.maxConnections || 0}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useCallback, useState } from 'react';
import { Server } from '@/lib/types/server';
import { User, hasPermission } from '@/lib/types/user';
import { ServerCard } from './ServerCard';
import { CreateServerModal } from './CreateServerModal';
import RefreshButton from '@/components/ui/RefreshButton';
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
interface ServerListWithActionsProps {
servers: Server[];
user: User;
}
export function ServerListWithActions({ servers, user }: ServerListWithActionsProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { isSteamCMDRunning } = useSteamCMD();
const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []);
const canCreateServer = hasPermission(user, 'server.create');
return (
<>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold">Your Servers</h2>
<div className="flex items-center space-x-4">
{canCreateServer && (
<button
onClick={() => setIsCreateModalOpen(true)}
disabled={isSteamCMDRunning}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
title={isSteamCMDRunning ? 'Server creation disabled while SteamCMD is running' : ''}
>
Create Server
</button>
)}
<RefreshButton />
</div>
</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} user={user} />
))}
</div>
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
interface StatCardProps {
title: string;
value: string | number;
icon: string;
}
export function StatCard({ title, value, icon }: StatCardProps) {
return (
<div className="rounded-lg bg-gray-800 p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-3xl">{icon}</span>
</div>
<div className="ml-4">
<div className="text-2xl font-semibold text-white">{value}</div>
<div className="text-sm text-gray-400">{title}</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
'use client';
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
export function SteamCMDNotification() {
const { isSteamCMDRunning, runningSteamServers } = useSteamCMD();
if (!isSteamCMDRunning) {
return null;
}
const serverCount = runningSteamServers.size;
return (
<div className="bg-yellow-600 border-l-4 border-yellow-400 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow-50"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-50">
<strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}.
Server actions are temporarily disabled to prevent conflicts.
This will automatically resolve when the download completes.
</p>
</div>
<div className="ml-auto flex-shrink-0">
<div className="flex items-center">
<div className="animate-spin h-4 w-4 border-2 border-yellow-50 border-t-transparent rounded-full mr-2"></div>
<span className="text-xs text-yellow-50">Downloading...</span>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useRef } from 'react';
import { useWebSocket } from '@/lib/websocket/context';
interface WebSocketInitializerProps {
openToken?: string;
}
export function WebSocketInitializer({ openToken }: WebSocketInitializerProps) {
const { connect, isConnected } = useWebSocket();
const hasInitialized = useRef(false);
useEffect(() => {
if (openToken && !isConnected && !hasInitialized.current) {
hasInitialized.current = true;
connect(openToken).catch((error) => {
console.error('Failed to connect WebSocket:', error);
hasInitialized.current = false;
});
}
}, [openToken, connect, isConnected]);
return null;
}

View File

@@ -1,10 +0,0 @@
import type { ServerInit } from '@sveltejs/kit';
import { redisSessionManager } from '$stores/redisSessionManager';
import type Redis from 'ioredis';
const redisClient: Redis = redisSessionManager['redisClient'];
export const init: ServerInit = async () => {
console.log(redisClient.status);
if (redisClient.status == 'connect') return;
await redisClient.connect();
};

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

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { createServer, deleteServer } from '@/lib/api/server/servers';
export type ServerActionData = {
id: string;
};
export type ServerActionResult = {
success: boolean;
message: string;
data?: ServerActionData;
};
export async function createServerAction(
prevState: ServerActionResult,
formData: FormData
): Promise<ServerActionResult> {
try {
const session = await requireAuth();
const name = formData.get('name') as string;
if (!name?.trim()) {
return {
success: false,
message: 'Server name is required'
};
}
const server = await createServer(session.token!, name.trim());
revalidatePath('/dashboard');
return {
success: true,
message: 'Server creation started',
data: server
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to create server'
};
}
}
export async function deleteServerAction(serverId: string): Promise<ServerActionResult> {
try {
const session = await requireAuth();
await deleteServer(session.token!, serverId);
revalidatePath('/dashboard');
return {
success: true,
message: 'Server deleted successfully'
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to delete server'
};
}
}

View File

@@ -0,0 +1,62 @@
'use server';
import { revalidatePath, revalidateTag } 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}`);
revalidateTag('/server');
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to start server'
};
}
}
export async function startServerEventAction(serverId: string) {
await startServerAction(serverId);
}
export async function stopServerAction(serverId: string) {
try {
const session = await requireAuth();
await stopService(session.token!, serverId);
revalidatePath('/dashboard');
revalidatePath(`/dashboard/server/${serverId}`);
revalidateTag('/server');
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to stop server'
};
}
}
export async function stopServerEventAction(serverId: string) {
await stopServerAction(serverId);
}
export async function restartServerAction(serverId: string) {
try {
const session = await requireAuth();
await restartService(session.token!, serverId);
revalidatePath('/dashboard');
revalidatePath(`/dashboard/server/${serverId}`);
revalidateTag('/server');
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to restart server'
};
}
}
export async function restartServerEventAction(serverId: string) {
await restartServerAction(serverId);
}

View File

@@ -0,0 +1,55 @@
'use client';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
export type ClientApiResponse<T> = {
data?: T;
error?: string;
message?: string;
};
export async function fetchClientAPI<T>(
endpoint: string,
method: string = 'GET',
body?: object,
customToken?: string
): Promise<ClientApiResponse<T>> {
let token = customToken;
if (!token) {
const response = await fetch('/api/session');
if (response.ok) {
const session = await response.json();
token = session.openToken;
}
if (!token) {
throw new Error('No authentication token available');
}
}
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) {
if (response.status === 401) {
window.location.href = '/logout';
return { error: 'unauthorized' };
}
throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`);
}
if (response.headers.get('Content-Type')?.includes('application/json')) {
return { data: await response.json() };
}
return { message: await response.text() };
}

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
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) {
if (response.status === 401) {
throw new Error(`Invalid credentials`);
}
throw new Error(`Login failed: ${response.statusText}`);
}
const { token } = await response.json();
const userResponse = await getCurrentUser(token);
return { token, user: userResponse };
}
export async function getCurrentUser(token: string): Promise<User> {
const response = await fetchServerAPI<User>(`${authRoute}/me`, token);
return response.data!;
}
export async function getOpenToken(token: string): Promise<string> {
const response = await fetchServerAPI<{ token: string }>(
`${authRoute}/open-token`,
token,
'POST'
);
return response.data!.token;
}

View File

@@ -0,0 +1,44 @@
import { redirect } from 'next/navigation';
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
type ApiResponse<T> = {
data?: T;
error?: string;
message?: string;
};
export async function fetchServerAPI<T>(
endpoint: string,
token: string,
method: string = 'GET',
body?: object
): Promise<ApiResponse<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,
next: { tags: [endpoint] }
});
if (!response.ok) {
if (response.status == 401) {
redirect('/logout');
return { error: 'unauthorized' };
}
throw new Error(
`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint} - ${token}`
);
}
if (response.headers.get('Content-Type')?.includes('application/json')) {
return { data: await response.json() };
}
return { message: await response.text() };
}

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
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}` : ''}`;
const response = await fetchServerAPI<User[]>(endpoint, token);
return response.data!;
}
export async function createUser(
token: string,
userData: { username: string; password: string; role: string }
): Promise<void> {
await fetchServerAPI(membershipRoute, token, 'POST', userData);
}
export async function getUserById(token: string, userId: string): Promise<User> {
const response = await fetchServerAPI<User>(`${membershipRoute}/${userId}`, token);
return response.data!;
}
export async function updateUser(
token: string,
userId: string,
userData: Partial<User>
): Promise<void> {
await fetchServerAPI(`${membershipRoute}/${userId}`, token, 'PUT', userData);
}
export async function deleteUser(token: string, userId: string): Promise<void> {
await fetchServerAPI(`${membershipRoute}/${userId}`, token, 'DELETE');
}
export async function getRoles(token: string): Promise<Role[]> {
const response = await fetchServerAPI<Role[]>(`${membershipRoute}/roles`, token);
return response.data!;
}

View File

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

View File

@@ -0,0 +1,17 @@
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> {
const response = await fetchServerAPI<StateHistoryStats>(
`${serverRoute}/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`,
token
);
return response.data!;
}

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

@@ -0,0 +1,32 @@
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('/logout');
}
return session;
}
export async function login(token: string, user: SessionData['user'], openToken?: string) {
const session = await getSession();
session.token = token;
session.user = user;
session.openToken = openToken;
await session.save();
}
export async function logout() {
const session = await getSession();
session.destroy();
}

View File

@@ -0,0 +1,61 @@
'use client';
import { createContext, useContext, useState, ReactNode, useCallback } from 'react';
interface ServerCreationPopupState {
serverId: string;
serverName: string;
isOpen: boolean;
}
interface ServerCreationPopupContextType {
popup: ServerCreationPopupState | null;
showPopup: (serverId: string, serverName: string) => void;
hidePopup: () => void;
isPopupOpen: boolean;
}
const ServerCreationPopupContext = createContext<ServerCreationPopupContextType | null>(null);
export function useServerCreationPopup() {
const context = useContext(ServerCreationPopupContext);
if (!context) {
throw new Error('useServerCreationPopup must be used within a ServerCreationPopupProvider');
}
return context;
}
interface ServerCreationPopupProviderProps {
children: ReactNode;
}
export function ServerCreationPopupProvider({ children }: ServerCreationPopupProviderProps) {
const [popup, setPopup] = useState<ServerCreationPopupState | null>(null);
const showPopup = useCallback((serverId: string, serverName: string) => {
setPopup({
serverId,
serverName,
isOpen: true
});
}, []);
const hidePopup = useCallback(() => {
setPopup(null);
}, []);
const isPopupOpen = popup?.isOpen || false;
return (
<ServerCreationPopupContext.Provider
value={{
popup,
showPopup,
hidePopup,
isPopupOpen
}}
>
{children}
</ServerCreationPopupContext.Provider>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context';
import { WebSocketMessage, StepData } from '@/lib/websocket/client';
interface SteamCMDContextType {
isSteamCMDRunning: boolean;
runningSteamServers: Set<string>;
dissociateServer: (serverId: string) => void;
}
const SteamCMDContext = createContext<SteamCMDContextType | null>(null);
export function useSteamCMD() {
const context = useContext(SteamCMDContext);
if (!context) {
throw new Error('useSteamCMD must be used within a SteamCMDProvider');
}
return context;
}
interface SteamCMDProviderProps {
children: ReactNode;
}
export function SteamCMDProvider({ children }: SteamCMDProviderProps) {
const [runningSteamServers, setRunningSteamServers] = useState<Set<string>>(new Set());
const { addMessageHandler, removeMessageHandler } = useWebSocket();
const isSteamCMDRunning = runningSteamServers.size > 0;
useEffect(() => {
const handleWebSocketMessage = (message: WebSocketMessage) => {
if (message.type === 'step') {
const data = message.data as StepData;
if (data.step === 'steam_download') {
setRunningSteamServers((prev) => {
const newSet = new Set(prev);
if (data.status === 'in_progress') {
newSet.add(message.server_id);
} else if (data.status === 'completed' || data.status === 'failed') {
newSet.delete(message.server_id);
}
return newSet;
});
}
}
};
addMessageHandler(handleWebSocketMessage);
return () => {
removeMessageHandler(handleWebSocketMessage);
};
}, [addMessageHandler, removeMessageHandler]);
const dissociateServer = useCallback((serverId: string) => {
setRunningSteamServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverId);
return newSet;
});
}, []);
return (
<SteamCMDContext.Provider
value={{
isSteamCMDRunning,
runningSteamServers,
dissociateServer
}}
>
{children}
</SteamCMDContext.Provider>
);
}

View File

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

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

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

View File

@@ -1,21 +1,13 @@
export interface Configurations {
configuration: Configuration;
assistRules: AssistRules;
event: EventConfig;
eventRules: EventRules;
settings: ServerSettings;
}
export enum configFile {
export enum ConfigFile {
configuration = 'configuration.json',
assistRules = 'assistRules.json',
event = 'event.json',
eventRules = 'eventRules.json',
settings = 'settings.json'
}
export enum serverTab {
export enum ServerTab {
statistics = 'statistics',
statistics2 = 'statistics2',
configuration = 'configuration',
assistRules = 'assistRules',
event = 'event',
@@ -23,58 +15,15 @@ export enum serverTab {
settings = 'settings'
}
export interface StateHistory {
dateCreated: string;
sessionStart: string;
playerCount: number;
track: string;
sessionDurationMinutes: number;
session: string;
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 +56,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 +79,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 +92,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;

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

@@ -0,0 +1,5 @@
export * from './server';
export * from './user';
export * from './config';
export * from './statistics';
export * from './lookups';

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