70 Commits

Author SHA1 Message Date
4cc005d309 feat: api health check and version display 2026-03-22 12:34:33 +01:00
9d22027f23 fix: lift editor form state to parent so it persists across tab switches 2026-03-22 00:19:49 +01:00
de41d2566c feat: update favicon 2026-03-21 23:12:06 +01:00
004ba49de3 fix: extend server cards 2026-03-21 22:49:10 +01:00
3cf56b335c fix: add missing logout button 2026-03-21 22:42:22 +01:00
b2f73a0b95 fix: create modal sizing 2026-03-21 22:20:41 +01:00
5360bb51c4 feat: restyle LogoutButton and RefreshButton, complete redesign 2026-03-21 22:18:33 +01:00
71fa228013 refactor: remove gh/GitHub references from tokens, classes, and component names
Rename all --color-gh-* CSS tokens to --color-*, update Tailwind class names
(bg-gh-*, text-gh-*, border-gh-*, etc.) across all TSX/TS files, rename
GhButton component and file to Button, and remove trivial descriptive comments.
2026-03-21 22:11:13 +01:00
3c643d5dcc feat: restyle server create/delete modals to GitHub-dark
Apply GH-dark design tokens to Modal container, CreateServerModal, and
DeleteServerModal: overlay/canvas card pattern, form-input class, GhButton
variants (primary, ghost, danger-solid), and GH token text/border colors.
2026-03-21 21:57:57 +01:00
c99eebde05 feat: restyle membership page — filters, user table, modals 2026-03-21 21:51:26 +01:00
4f7705ec7c fix: use GhButton for leaderboard save button for consistent focus ring 2026-03-21 21:49:00 +01:00
8a7299fba2 feat: restyle leaderboard tab — pill nav, unsaved changes bar 2026-03-21 21:45:21 +01:00
db0b1491bd fix: add focus:ring-1 to checkbox elements in config editors 2026-03-21 21:44:15 +01:00
097d9d99fc feat: restyle all 5 config form tabs to GitHub-dark
Replace gray-* tokens with gh-* design tokens across all config editors:
labels → text-gh-secondary, headings → border-gh-border-muted/text-gh-primary,
checkboxes → gh-overlay/gh-green/gh-blue, buttons → gh-btn-green, selects → form-select.
2026-03-21 21:42:01 +01:00
14faaf87b7 fix: correct operator precedence in totalPlaytime calculation 2026-03-21 21:30:06 +01:00
9d240c3a80 feat: restyle statistics tab — stat cards, charts, recent sessions table 2026-03-21 21:27:18 +01:00
374899e839 fix: remove duplicate maxConnections info, use h-screen utility 2026-03-21 21:24:39 +01:00
da92ec72a7 feat: restyle server detail — breadcrumb topbar, info strip, tab bar 2026-03-21 21:18:33 +01:00
a5e50fe8b7 fix: remove duplicate card subtitle, improve disable logic, inclusive offline count 2026-03-21 21:16:26 +01:00
025c021896 feat: restyle dashboard — card grid (desktop), row list (mobile), status badges 2026-03-21 21:12:50 +01:00
a4ff0a9339 fix: use matching border color for login button resting state 2026-03-21 21:09:39 +01:00
45a67ebfb8 feat: restyle login page to GitHub-dark 2026-03-21 21:07:36 +01:00
5a7485f654 fix: explicit sidebar active state, popup clears mobile bottom bar 2026-03-21 21:06:25 +01:00
710ac253e2 feat: add AppShell with persistent icon sidebar and mobile bottom tab bar 2026-03-21 20:59:09 +01:00
5c69ef716b fix: use design tokens for primary button green, add gap to GhButton 2026-03-21 20:56:43 +01:00
1d13def6da feat: add Badge, StatusDot, GhButton shared UI primitives 2026-03-21 20:41:06 +01:00
2801f80b9a feat: add GitHub-dark design tokens via Tailwind v4 @theme 2026-03-21 20:37:15 +01:00
75d88257c8 resolve warnings and type errors 2026-03-19 21:31:07 +01:00
f8510c8db3 update fastestLapDriverId to be nullable 2026-03-01 17:03:39 +01:00
e2f86fbcb6 update leaderboard schema to match new API wire format with flattened FL points 2026-03-01 16:41:16 +01:00
adf123f56a migrate leaderboard to driver ID-based wire format 2026-03-01 16:22:35 +01:00
3ba68ac973 add 0 fallaback 2026-03-01 15:04:09 +01:00
67a72f9a92 fix leaderboard 2026-03-01 14:33:21 +01:00
51c45ad4a9 fix build errors 2026-03-01 12:23:38 +01:00
61f0b69176 Merge branch 'feature/leaderboard' 2026-03-01 12:17:13 +01:00
2976468c9f update results with DNS as default 2026-02-28 18:02:43 +01:00
d56a399e8e add cota and resolve fetch issues 2026-02-22 17:50:09 +01:00
8dc8f98add add leaderboard management 2026-02-22 12:41:28 +01:00
d8d008b5e8 add missing tracks 2025-12-20 15:16:24 +01:00
51f45a806e add nullable to state 2025-11-15 22:40:09 +01:00
5b8698cf81 add bool to int mapping 2025-10-14 00:30:57 +02:00
69a3836f13 fix wrong type 2025-10-14 00:23:43 +02:00
69d92a3fc9 fix config rules and add override to config files 2025-10-14 00:06:26 +02:00
e8bf8498b8 update config rules 2025-09-27 22:14:20 +02:00
Fran Jurmanović
f15a0175b8 install zod 2025-09-24 20:16:14 +02:00
Fran Jurmanović
996f1a1385 introduce zod schemas 2025-09-24 20:07:57 +02:00
Fran Jurmanović
0cbc6935db use login?expired=true for unauthorized logout 2025-09-22 23:29:30 +02:00
Fran Jurmanović
6563396a83 fix logout issues 2025-09-22 22:49:15 +02:00
Fran Jurmanović
c005090ab1 resolve logout issues 2025-09-22 20:45:02 +02:00
Fran Jurmanović
6aeb654abf lazy load socket connection 2025-09-21 22:53:23 +02:00
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
157 changed files with 11132 additions and 6206 deletions

52
.gitignore vendored Normal file → Executable file
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

0
.prettierignore Normal file → Executable file
View File

10
.prettierrc Normal file → Executable file
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"],
}

40
Dockerfile Normal file → Executable file
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.

52
README.md Normal file → Executable file
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 Executable 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 Executable file
View File

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

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

File diff suppressed because it is too large Load Diff

79
package.json Normal file → Executable file
View File

@@ -1,52 +1,47 @@
{
"name": "acc-server-manager-web",
"version": "0.20.2",
"private": true,
"version": "0.10.0",
"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",
"zod": "^4.1.11"
},
"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 Executable file
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
src/app.d.ts vendored
View File

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

View File

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

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

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

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: Promise<{
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 resolvedParams = await searchParams;
const params: UserListParams = {
username: resolvedParams.username,
role_name: resolvedParams.role_name,
sort_by: resolvedParams.sort_by || 'username',
sort_desc: resolvedParams.sort_desc === 'true',
page: parseInt(resolvedParams.page || '1'),
limit: 20
};
const [userListData, roles] = await Promise.all([
getUsers(session.token!, params),
getRoles(session.token!)
]);
return (
<UserManagementTable initialData={userListData} roles={roles} currentUser={session.user!} />
);
}

16
src/app/dashboard/page.tsx Executable file
View File

@@ -0,0 +1,16 @@
import { requireAuth } from '@/lib/auth/server';
import { getServers } from '@/lib/api/server/servers';
import { ServerListWithActions } from '@/components/server/ServerListWithActions';
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
export default async function DashboardPage() {
const session = await requireAuth();
const servers = await getServers(session.token!);
return (
<main>
<SteamCMDNotification />
<ServerListWithActions servers={servers} user={session.user!} />
</main>
);
}

View File

@@ -0,0 +1,41 @@
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 { getLeaderboard } from '@/lib/api/server/leaderboard';
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, leaderboard] = await Promise.all([
getServer(session.token!, id),
getServerConfigurations(session.token!, id),
getServerStatistics(session.token!, id, { startDate, endDate }),
getLeaderboard(session.token!, id)
]);
return (
<div className="flex flex-col overflow-hidden h-screen">
<ServerHeader server={server} user={session.user!} />
<ServerConfigurationTabs
serverId={id}
configurations={configurations}
statistics={statistics}
leaderboard={leaderboard}
/>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

35
src/app/globals.css Executable file
View File

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

19
src/app/layout.tsx Executable file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
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-base text-primary antialiased">{children}</body>
</html>
);
}

18
src/app/login/page.tsx Executable file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
'use client';
import type { AssistRules } from '@/lib/schemas/config';
interface AssistRulesEditorProps {
formData: AssistRules;
disabled?: boolean;
onFormDataChange: (data: AssistRules) => void;
}
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'
}
];
export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
const handleInputChange = (key: keyof AssistRules, value: string | number) => {
onFormDataChange({
...formData,
[key]: typeof value === 'string' ? parseInt(value) : value
});
};
return (
<div 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-1.5 block text-sm font-medium text-secondary">{label}</label>
{type === 'number' ? (
<input
type="number"
disabled={disabled}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
min="0"
max="5"
/>
) : (
<select
disabled={disabled}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-select w-full"
>
<option value={0}>Allowed</option>
<option value={1}>Disabled</option>
</select>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import type { Configuration } from '@/lib/schemas/config';
interface ConfigurationEditorProps {
formData: Configuration;
disabled?: boolean;
onFormDataChange: (data: Configuration) => void;
}
export function ConfigurationEditor({ formData, disabled, onFormDataChange }: ConfigurationEditorProps) {
const handleInputChange = (key: keyof Configuration, value: string | number) => {
onFormDataChange({
...formData,
[key]: typeof value === 'string' ? parseInt(value) : value
});
};
return (
<div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label>
<input
type="number"
disabled={disabled}
value={formData.udpPort}
onChange={(e) => handleInputChange('udpPort', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label>
<input
type="number"
disabled={disabled}
value={formData.tcpPort}
onChange={(e) => handleInputChange('tcpPort', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label>
<input
type="number"
disabled={disabled}
value={formData.maxConnections}
onChange={(e) => handleInputChange('maxConnections', e.target.value)}
className="form-input w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label>
<select
disabled={disabled}
value={formData.lanDiscovery}
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
className="form-select w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label>
<select
disabled={disabled}
value={formData.registerToLobby}
onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
className="form-select w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,369 @@
'use client';
import type { EventConfig, Session } from '@/lib/schemas/config';
interface EventConfigEditorProps {
formData: EventConfig;
disabled?: boolean;
onFormDataChange: (data: EventConfig) => void;
}
const sessionTypes = [
{ value: 'P', label: 'Practice' },
{ value: 'Q', label: 'Qualifying' },
{ value: 'R', label: 'Race' }
];
export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) {
const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return;
onFormDataChange({
...formData,
[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
};
onFormDataChange({ ...formData, sessions: newSessions });
};
const addSession = () => {
const newSession: Session = {
hourOfDay: 12,
dayOfWeekend: 1,
timeMultiplier: 1,
sessionType: 'P',
sessionDurationMinutes: 20
};
onFormDataChange({ ...formData, sessions: [...formData.sessions, newSession] });
};
const removeSession = (index: number) => {
onFormDataChange({ ...formData, sessions: formData.sessions.filter((_, i) => i !== index) });
};
return (
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Event Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Track</label>
<select
disabled={disabled}
value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)}
className="form-select w-full"
>
<option value="barcelona">Barcelona</option>
<option value="brands_hatch">Brands Hatch</option>
<option value="donington">Donington</option>
<option value="cota">COTA</option>
<option value="hungaroring">Hungaroring</option>
<option value="imola">Imola</option>
<option value="indianapolis">Indianapolis</option>
<option value="kyalami">Kyalami</option>
<option value="laguna_seca">Laguna Seca</option>
<option value="misano">Misano</option>
<option value="monza">Monza</option>
<option value="mount_panorama">Mount Panorama</option>
<option value="nurburgring">Nurburgring</option>
<option value="nurburgring_24h">Nurburgring 24h</option>
<option value="oulton_park">Oulton Park</option>
<option value="paul_ricard">Paul Ricard</option>
<option value="red_bull_ring">Red Bull Ring</option>
<option value="silverstone">Silverstone</option>
<option value="snetterton">Snetterton</option>
<option value="spa">Spa-Francorchamps</option>
<option value="suzuka">Suzuka</option>
<option value="valencia">Valencia</option>
<option value="zandvoort">Zandvoort</option>
<option value="zolder">Zolder</option>
<option value="watkins_glen">Watkins Glen</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Pre-Race Waiting Time (seconds)
</label>
<input
type="number"
disabled={disabled}
value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Session Over Time (seconds)
</label>
<input
type="number"
disabled={disabled}
value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Post Qualify Seconds
</label>
<input
type="number"
disabled={disabled}
value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full"
min="0"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Post Race Seconds
</label>
<input
type="number"
disabled={disabled}
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-border-muted pb-2 text-sm font-semibold text-primary">
Weather Settings
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Ambient Temperature (°C)
</label>
<input
type="number"
disabled={disabled}
value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full"
min="0"
max="50"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Cloud Level (0.0-1.0)
</label>
<input
type="number"
disabled={disabled}
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-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label>
<input
type="number"
disabled={disabled}
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-1.5 block text-sm font-medium text-secondary">
Weather Randomness
</label>
<input
type="number"
disabled={disabled}
value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full"
min="0"
max="7"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Simracer Weather Conditions
</label>
<select
disabled={disabled}
value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-select w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Fixed Condition Qualification
</label>
<select
disabled={disabled}
value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-select 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-border-muted pb-2">
<h3 className="text-sm font-semibold text-primary">Sessions</h3>
<button
type="button"
onClick={addSession}
disabled={disabled}
className="rounded-md border border-border bg-overlay px-3 py-1.5 text-xs font-medium text-secondary hover:bg-border disabled:opacity-40"
>
+ Add Session
</button>
</div>
<div className="space-y-4">
{formData.sessions.map((session, index) => (
<div key={index} className="rounded-lg border border-border bg-canvas p-4">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-medium text-primary">Session {index + 1}</h4>
<button
type="button"
onClick={() => removeSession(index)}
disabled={disabled}
className="rounded px-2 py-1 text-xs text-red hover:bg-red-bg disabled:opacity-40"
>
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-muted">
Session Type
</label>
<select
disabled={disabled}
value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-select 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-muted">
Hour of Day
</label>
<input
type="number"
disabled={disabled}
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-muted">
Day of Weekend
</label>
<input
type="number"
disabled={disabled}
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-muted">
Time Multiplier
</label>
<input
type="number"
disabled={disabled}
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-muted">
Duration (minutes)
</label>
<input
type="number"
disabled={disabled}
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>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import type { EventRules } from '@/lib/schemas/config';
interface EventRulesEditorProps {
formData: EventRules;
disabled?: boolean;
onFormDataChange: (data: EventRules) => void;
}
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'
}
];
export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
const handleInputChange = (key: keyof EventRules, value: number) => {
onFormDataChange({ ...formData, [key]: value });
};
return (
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{numberFields.map(({ key, label, min, max }) => (
<div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type="number"
disabled={disabled}
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-border-muted pb-2 text-sm font-semibold text-primary">
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-1.5 block text-sm font-medium text-secondary">{label}</label>
<select
disabled={disabled}
value={formData[key] ? 'true' : 'false'}
onChange={(e) => handleInputChange(key, e.target.value === 'true' ? 1 : 0)}
className="form-select w-full"
>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import type { ServerSettings } from '@/lib/schemas/config';
interface ServerSettingsEditorProps {
formData: ServerSettings;
disabled?: boolean;
onFormDataChange: (data: ServerSettings) => void;
}
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'
}
];
export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
onFormDataChange({ ...formData, [key]: value });
};
return (
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
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-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type={type}
disabled={disabled}
value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
/>
</div>
))}
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label>
<select
disabled={disabled}
value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-select 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-border-muted pb-2 text-sm font-semibold text-primary">
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-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type="number"
disabled={disabled}
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-border-muted pb-2 text-sm font-semibold text-primary">
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-1.5 block text-sm font-medium text-secondary">{label}</label>
<select
disabled={disabled}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-select w-full"
>
<option value={0}>No</option>
<option value={1}>Yes</option>
</select>
</div>
))}
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">
Formation Lap Type
</label>
<select
disabled={disabled}
value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-select 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import type { Role } from '@/lib/schemas';
import { createUserAction } from '@/lib/actions/membership';
import { Button } from '@/components/ui/Button';
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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl">
<h2 className="mb-4 text-base font-semibold text-primary">Create New User</h2>
{error && (
<div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="create-username"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Username
</label>
<input
id="create-username"
type="text"
value={formData.username}
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
required
disabled={isSubmitting}
className="form-input"
/>
</div>
<div className="mb-4">
<label
htmlFor="create-password"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Password
</label>
<input
id="create-password"
type="password"
value={formData.password}
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
required
disabled={isSubmitting}
className="form-input"
/>
</div>
<div className="mb-6">
<label
htmlFor="create-role"
className="mb-1.5 block text-sm font-medium text-secondary"
>
Role
</label>
<select
id="create-role"
value={formData.role}
onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))}
required
disabled={isSubmitting}
className="form-input"
>
<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 gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create User'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import type { User } from '@/lib/schemas';
import { deleteUserAction } from '@/lib/actions/membership';
import { Button } from '@/components/ui/Button';
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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl">
<h2 className="mb-4 text-base font-semibold text-primary">Delete User</h2>
{error && (
<div className="mb-4 rounded-md border border-red/20 bg-red-bg p-3 text-sm text-red">
{error}
</div>
)}
<p className="mb-6 text-sm text-secondary">
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 gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="danger-solid" disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete User'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Role, User } from '@/lib/schemas';
import { hasPermission } from '@/lib/schemas/user';
import { CreateUserModal } from './CreateUserModal';
import { DeleteUserModal } from './DeleteUserModal';
import { Button } from '@/components/ui/Button';
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="flex h-12 items-center justify-between border-b border-border-muted px-5">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-primary">User Management</span>
</div>
{hasPermission(currentUser, 'membership.create') && (
<Button variant="primary" size="sm" onClick={openCreateModal}>
+ Create User
</Button>
)}
</header>
<div className="mx-5 mt-4 rounded-lg border border-border bg-canvas p-4">
<div className="mb-3 text-sm font-semibold text-primary">Filters</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label htmlFor="username" className="mb-1 block text-xs font-medium text-muted">
Username
</label>
<input
id="username"
type="text"
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
placeholder="Search by username..."
className="form-input"
/>
</div>
<div>
<label htmlFor="role" className="mb-1 block text-xs font-medium text-muted">
Role
</label>
<input
id="role"
type="text"
value={roleNameFilter}
onChange={(e) => setRoleNameFilter(e.target.value)}
placeholder="Filter by role..."
className="form-input"
/>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button variant="primary" size="sm" onClick={applyFilters}>
Apply
</Button>
<Button variant="ghost" size="sm" onClick={resetFilters}>
Reset
</Button>
</div>
</div>
<div className="mx-5 mt-3 text-xs text-muted">Showing {initialData.length} users</div>
<div className="mx-5 mt-2 mb-5 overflow-hidden rounded-lg border border-border bg-canvas">
<table className="min-w-full">
<thead>
<tr className="border-b border-border-muted bg-base">
<th className="px-4 py-2 text-left">
<button
onClick={() => handleSort('username')}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-subtle hover:text-primary"
>
Username <span>{getSortIcon('username')}</span>
</button>
</th>
<th className="px-4 py-2 text-left">
<button
onClick={() => handleSort('role')}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-subtle hover:text-primary"
>
Role <span>{getSortIcon('role')}</span>
</button>
</th>
{hasPermission(currentUser, 'membership.edit') && (
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Actions
</th>
)}
</tr>
</thead>
<tbody>
{initialData.length > 0 ? (
initialData.map((user) => (
<tr
key={user.id}
className="border-b border-border-muted transition-colors hover:bg-hover last:border-0"
>
<td className="px-4 py-3 text-sm font-medium text-primary">
{user.username}
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-blue/20 bg-blue-bg px-2 py-0.5 text-xs font-medium text-blue">
{user.role.name}
</span>
</td>
{hasPermission(currentUser, 'membership.edit') && (
<td className="px-4 py-3">
<div className="flex gap-3">
<button className="text-xs text-blue hover:underline">
Edit
</button>
{hasPermission(currentUser, 'membership.delete') && (
<button
onClick={() => openDeleteModal(user)}
className="text-xs text-red hover:underline"
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))
) : (
<tr>
<td
colSpan={hasPermission(currentUser, 'membership.edit') ? 3 : 2}
className="px-4 py-8 text-center text-sm text-muted"
>
No users found
</td>
</tr>
)}
</tbody>
</table>
</div>
{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,111 @@
'use client';
import { useState, useEffect, useActionState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Button } from '@/components/ui/Button';
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 border border-red/20 bg-red-bg p-3 text-sm text-red">{state.message}</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label htmlFor="server-name" className="mb-1.5 block text-sm font-medium text-secondary">
Server Name
</label>
<input
id="server-name"
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
required
disabled={isSubmitting || isPending}
className="form-input"
placeholder="Enter server name..."
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="ghost"
size="md"
onClick={handleClose}
disabled={isSubmitting || isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="md"
disabled={isSubmitting || !serverName.trim() || isPending}
>
{isSubmitting ? (
<>
<LoadingSpinner size="sm" />
Creating...
</>
) : (
'Create Server'
)}
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Button } from '@/components/ui/Button';
import { deleteServerAction } from '@/lib/actions/server-management';
import { Server } from '@/lib/schemas/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 border border-red/20 bg-red-bg p-3 text-sm text-red">{error}</div>}
<div className="mb-6">
<p className="text-secondary">
Are you sure you want to delete the server <strong>&quot;{server.name}&quot;</strong>?
</p>
<p className="mt-2 text-sm text-muted">This action cannot be undone.</p>
</div>
<div className="flex justify-end space-x-2">
<Button
variant="ghost"
size="md"
onClick={handleClose}
disabled={isPending}
>
Cancel
</Button>
<Button
variant="danger-solid"
size="md"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? (
<>
<LoadingSpinner size="sm" />
Deleting...
</>
) : (
'Delete Server'
)}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import Link from 'next/link';
import { useTransition } from 'react';
import { Server, ServiceStatus, serviceStatusToString } from '@/lib/schemas';
import {
startServerEventAction,
restartServerEventAction,
stopServerEventAction
} from '@/lib/actions/servers';
import { useRouter } from 'next/navigation';
import { StatusDot } from '@/components/ui/StatusDot';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface ServerCardProps {
server: Server;
}
function statusBadgeVariant(status: ServiceStatus): 'green' | 'yellow' | 'gray' {
if (status === ServiceStatus.Running) return 'green';
if (
[ServiceStatus.Starting, ServiceStatus.Restarting, ServiceStatus.Stopping].includes(status)
)
return 'yellow';
return 'gray';
}
export function ServerCard({ server }: ServerCardProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const startServer = () =>
startTransition(async () => {
await startServerEventAction(server.id);
router.refresh();
});
const restartServer = () =>
startTransition(async () => {
await restartServerEventAction(server.id);
router.refresh();
});
const stopServer = () =>
startTransition(async () => {
await stopServerEventAction(server.id);
router.refresh();
});
const disabled = [
ServiceStatus.Restarting,
ServiceStatus.Starting,
ServiceStatus.Stopping,
ServiceStatus.Unknown,
].includes(server.status);
const isRunning = server.status === ServiceStatus.Running;
return (
<div className="overflow-hidden rounded-lg border border-border bg-canvas transition-colors hover:border-blue/40">
<Link href={`/dashboard/server/${server.id}`} className="block p-4">
<div className="flex items-start justify-between">
<span className="text-sm font-semibold text-primary">{server.name}</span>
<StatusDot status={server.status} />
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-subtle">Players</div>
<div className="text-sm font-semibold text-primary">
{server.state?.playerCount ?? 0}
</div>
</div>
<div>
<div className="text-xs text-subtle">Track</div>
<div className="truncate text-sm font-semibold text-primary">
{server.state?.track || 'N/A'}
</div>
</div>
</div>
<div className="mt-3">
<Badge variant={statusBadgeVariant(server.status)}>
{serviceStatusToString(server.status)}
</Badge>
</div>
</Link>
<div className="flex gap-1.5 border-t border-border-muted bg-base px-4 py-2">
<Button
variant="primary"
size="sm"
onClick={startServer}
disabled={isRunning || disabled || isPending}
>
Start
</Button>
<Button
variant="ghost"
size="sm"
onClick={restartServer}
disabled={!isRunning || disabled || isPending}
>
Restart
</Button>
<Button
variant="danger-outline"
size="sm"
onClick={stopServer}
disabled={!isRunning || disabled || isPending}
>
Stop
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { Configurations, ServerTab } from '@/lib/schemas/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 { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager';
import { useState } from 'react';
import { StateHistoryStats } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps {
serverId: string;
configurations: Configurations;
statistics: StateHistoryStats;
leaderboard: Leaderboard;
}
const tabs = [
{ id: ServerTab.statistics, name: 'Statistics', icon: '📊' },
{ id: ServerTab.configuration, name: 'Configuration', icon: '⚙️' },
{ id: ServerTab.assistRules, name: 'Assist Rules', icon: '🚗' },
{ id: ServerTab.event, name: 'Event Config', icon: '🏁' },
{ id: ServerTab.eventRules, name: 'Event Rules', icon: '📋' },
{ id: ServerTab.settings, name: 'Server Settings', icon: '🔧' },
{ id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' }
];
const configTabs = new Set([
ServerTab.configuration,
ServerTab.assistRules,
ServerTab.event,
ServerTab.eventRules,
ServerTab.settings
]);
export function ServerConfigurationTabs({
serverId,
configurations,
statistics,
leaderboard
}: ServerConfigurationTabsProps) {
const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const [configData, setConfigData] = useState(configurations.configuration);
const [assistData, setAssistData] = useState(configurations.assistRules);
const [eventData, setEventData] = useState(configurations.event);
const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
const [settingsData, setSettingsData] = useState(configurations.settings);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const handleSaveAll = async () => {
setIsSubmitting(true);
setSaveError(null);
setSaveSuccess(false);
const result = await bulkUpdateConfigurationsAction(
serverId,
{
configuration: configData,
assistRules: assistData,
event: eventData,
eventRules: eventRulesData,
settings: settingsData
},
restart
);
if (result.success) {
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} else {
setSaveError(result.message);
}
setIsSubmitting(false);
};
const isConfigTab = configTabs.has(currentTab);
const renderTabContent = () => {
switch (currentTab) {
case ServerTab.statistics:
return <StatisticsDashboard stats={statistics} />;
case ServerTab.configuration:
return (
<ConfigurationEditor
formData={configData}
disabled={isSubmitting}
onFormDataChange={setConfigData}
/>
);
case ServerTab.assistRules:
return (
<AssistRulesEditor
formData={assistData}
disabled={isSubmitting}
onFormDataChange={setAssistData}
/>
);
case ServerTab.event:
return (
<EventConfigEditor
formData={eventData}
disabled={isSubmitting}
onFormDataChange={setEventData}
/>
);
case ServerTab.eventRules:
return (
<EventRulesEditor
formData={eventRulesData}
disabled={isSubmitting}
onFormDataChange={setEventRulesData}
/>
);
case ServerTab.settings:
return (
<ServerSettingsEditor
formData={settingsData}
disabled={isSubmitting}
onFormDataChange={setSettingsData}
/>
);
case ServerTab.leaderboard:
return <LeaderboardManager serverId={serverId} initialData={leaderboard} />;
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="flex flex-1 flex-col overflow-hidden">
<nav className="flex overflow-x-auto border-b border-border-muted px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" aria-label="Tabs">
{tabs.map((tab) => {
const isActive = currentTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setCurrentTab(tab.id)}
className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-3 text-sm font-medium whitespace-nowrap transition-colors ${
isActive
? 'border-orange text-primary'
: 'border-transparent text-muted hover:text-primary'
}`}
>
<span className="text-sm">{tab.icon}</span>
<span>{tab.name}</span>
</button>
);
})}
</nav>
{isConfigTab && (
<div className="flex items-center justify-between border-b border-border-muted bg-overlay px-5 py-3">
<div className="flex items-center gap-3">
{saveError && (
<p className="text-sm text-red">{saveError}</p>
)}
{saveSuccess && (
<p className="text-sm text-green">All configurations saved.</p>
)}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart after saving
</label>
<button
onClick={handleSaveAll}
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save All'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div>
</div>
);
}

View File

@@ -0,0 +1,477 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useWebSocket } from '@/lib/websocket/context';
import type {
WebSocketMessage,
StepData,
SteamOutputData,
ErrorData,
CompleteData
} from '@/lib/schemas/websocket';
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 = useCallback(() => {
if (consoleRef.current && !isMinimized && isConsoleVisible) {
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
}
}, [isMinimized, isConsoleVisible]);
useEffect(() => {
scrollToBottom();
}, [entries, scrollToBottom]);
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-20 md: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-20 md:bottom-4 z-40 max-h-[600px] w-96 rounded-lg border border-gray-700 bg-gray-800 shadow-2xl select-none">
<div className="flex items-center justify-between border-b border-gray-700 p-4">
<div className="flex items-center 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,36 @@
'use client';
import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext';
import { ServerCreationPopup } from './ServerCreationPopup';
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
export function ServerCreationPopupContainer() {
const { popup, hidePopup } = useServerCreationPopup();
const { dissociateServer } = useSteamCMD();
const router = useRouter();
const handleClose = useCallback(() => {
hidePopup();
if (popup) return dissociateServer(popup.serverId);
}, [popup, dissociateServer, hidePopup]);
if (!popup) return null;
const handleComplete = (success: boolean) => {
if (success) {
setTimeout(() => {
router.refresh();
}, 2000);
}
};
return (
<ServerCreationPopup
serverId={popup.serverId}
serverName={popup.serverName}
isOpen={popup.isOpen}
onClose={handleClose}
onComplete={handleComplete}
/>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import Link from 'next/link';
import { Server, serviceStatusToString, ServiceStatus } from '@/lib/schemas/server';
import {
startServerEventAction,
restartServerEventAction,
stopServerEventAction
} from '@/lib/actions/servers';
import { hasPermission, User } from '@/lib/schemas';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { DeleteServerModal } from './DeleteServerModal';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
interface ServerHeaderProps {
server: Server;
user: User;
}
export function ServerHeader({ server, user }: ServerHeaderProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const canDeleteServer = hasPermission(user, 'server.delete');
const startServer = () =>
startTransition(async () => {
await startServerEventAction(server.id);
router.refresh();
});
const restartServer = () =>
startTransition(async () => {
await restartServerEventAction(server.id);
router.refresh();
});
const stopServer = () =>
startTransition(async () => {
await stopServerEventAction(server.id);
router.refresh();
});
const disabled = [
ServiceStatus.Restarting,
ServiceStatus.Starting,
ServiceStatus.Stopping,
ServiceStatus.Unknown
].includes(server.status);
return (
<>
<header className="flex h-12 items-center justify-between border-b border-border-muted px-5">
<div className="flex items-center gap-2 text-sm">
<Link href="/dashboard" className="text-blue hover:underline">Servers</Link>
<span className="text-subtle">/</span>
<span className="font-semibold text-primary">{server.name}</span>
</div>
<div className="flex items-center gap-2">
<Badge variant={
server.status === ServiceStatus.Running ? 'green' :
server.status === ServiceStatus.Stopped ? 'gray' : 'yellow'
}>
● {serviceStatusToString(server.status)}
</Badge>
<Button variant="ghost" size="sm" onClick={startServer} disabled={server.status === ServiceStatus.Running || disabled || isPending}>Start</Button>
<Button variant="ghost" size="sm" onClick={restartServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Restart</Button>
<Button variant="danger-outline" size="sm" onClick={stopServer} disabled={server.status === ServiceStatus.Stopped || disabled || isPending}>Stop</Button>
{canDeleteServer && (
<Button variant="danger-solid" size="sm" onClick={() => setIsDeleteModalOpen(true)} disabled={disabled || isPending}>Remove</Button>
)}
</div>
</header>
<div className="flex flex-wrap gap-6 border-b border-border-muted px-5 py-3">
{[
{ label: 'Track', value: server.state?.track ?? 'N/A' },
{ label: 'Players', value: `${server.state?.playerCount ?? 0} / ${server.state?.maxConnections ?? 0}` },
{ label: 'Session', value: server.state?.session ?? 'N/A' },
].map(({ label, value }) => (
<div key={label}>
<div className="text-xs text-subtle">{label}</div>
<div className="text-sm font-semibold text-primary">{value}</div>
</div>
))}
</div>
<DeleteServerModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
server={server}
/>
</>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { useCallback, useState } from 'react';
import { Server, ServiceStatus } from '@/lib/schemas/server';
import { User, hasPermission } from '@/lib/schemas/user';
import { ServerCard } from './ServerCard';
import { ServerRow } from './ServerRow';
import { CreateServerModal } from './CreateServerModal';
import RefreshButton from '@/components/ui/RefreshButton';
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
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');
const runningCount = servers.filter((s) => s.status === ServiceStatus.Running).length;
const offlineCount = servers.filter((s) => s.status !== ServiceStatus.Running).length;
return (
<>
<header className="border-border-muted flex h-12 items-center justify-between border-b px-5">
<div className="flex items-center gap-3">
<span className="text-primary text-sm font-semibold">Servers</span>
<span className="text-muted text-xs">{servers.length} total</span>
</div>
<div className="flex items-center gap-2">
<RefreshButton />
{canCreateServer && (
<Button
variant="primary"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
disabled={isSteamCMDRunning}
title={isSteamCMDRunning ? 'Disabled while SteamCMD is running' : undefined}
>
+ New Server
</Button>
)}
</div>
</header>
<div className="flex items-center px-5 py-3">
<div className="flex gap-2">
{runningCount > 0 && <Badge variant="green">● {runningCount} running</Badge>}
{offlineCount > 0 && <Badge variant="gray">{offlineCount} offline</Badge>}
</div>
</div>
<div className="px-5 pb-5">
{/* Card grid — desktop only */}
<div
className="hidden gap-3 md:grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))' }}
>
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
{/* Row list — mobile only */}
<div className="flex flex-col gap-2 md:hidden">
{servers.map((server) => (
<ServerRow key={server.id} server={server} />
))}
</div>
</div>
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />
</>
);
}

View File

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

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: '#3fb950',
borderColor: '#3fb950',
borderWidth: 1
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: '#e6edf3'
}
}
},
scales: {
x: {
ticks: {
color: '#8b949e'
},
grid: {
color: '#21262d'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#8b949e',
stepSize: 1
},
grid: {
color: '#21262d'
}
}
}
};
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: '#58a6ff',
backgroundColor: '#58a6ff1a',
tension: 0.4
}
]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: '#e6edf3'
}
}
},
scales: {
x: {
type: 'time' as const,
time: {
unit: 'hour' as const
},
ticks: {
color: '#8b949e'
},
grid: {
color: '#21262d'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#8b949e',
stepSize: 1
},
grid: {
color: '#21262d'
}
}
}
};
return (
<div className="h-64">
<Line data={chartData} options={options} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
interface RecentSession {
id: string;
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="px-4 py-8 text-center text-sm text-muted">No recent sessions found</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b border-border-muted bg-base">
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Date
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Type
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Track
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Duration
</th>
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-subtle">
Players
</th>
</tr>
</thead>
<tbody>
{sessions.map((session) => (
<tr key={session.id} className="border-b border-border-muted transition-colors hover:bg-hover last:border-0">
<td className="px-4 py-3 text-sm text-muted">
{formatDate(session.date)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
session.type === 'R' ? 'bg-green-bg text-green' :
session.type === 'Q' ? 'bg-yellow-bg text-yellow' :
'bg-blue-bg text-blue'
}`}>
{session.type === 'R' ? 'Race' : session.type === 'Q' ? 'Qualify' : 'Practice'}
</span>
</td>
<td className="px-4 py-3 text-sm text-secondary">{session.track}</td>
<td className="px-4 py-3 text-sm text-muted">
{formatDuration(session.duration)}
</td>
<td className="px-4 py-3 text-sm text-secondary">
{session.players}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'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[];
}
const colors = [
'#3fb950', // green
'#58a6ff', // blue
'#f78166', // red
'#d29922' // yellow
];
export function SessionTypesChart({ data }: SessionTypesChartProps) {
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: '#e6edf3',
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,17 @@
interface StatCardProps {
title: string;
value: string | number;
icon: string;
}
export function StatCard({ title, value, icon }: StatCardProps) {
return (
<div className="rounded-lg border border-border bg-canvas p-4">
<div className="mb-1 flex items-center gap-2">
<span className="text-base">{icon}</span>
<span className="text-xs text-muted">{title}</span>
</div>
<div className="text-2xl font-bold text-primary">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import type { StateHistoryStats } from '@/lib/schemas/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-16 text-center">
<div className="mb-3 text-5xl">📊</div>
<h3 className="mb-1 text-base font-semibold text-primary">No Statistics Available</h3>
<p className="text-sm text-muted">No data found for the selected date range.</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard title="Total Sessions" value={stats.totalSessions ?? 0} icon="🏁" />
<StatCard
title="Total 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>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="lg:col-span-2 rounded-lg border border-border bg-canvas p-4">
<h3 className="mb-3 text-sm font-semibold text-primary">Player Count Over Time</h3>
<PlayerCountChart data={stats.playerCountOverTime ?? []} />
</div>
<div className="rounded-lg border border-border bg-canvas p-4">
<h3 className="mb-3 text-sm font-semibold text-primary">Session Types</h3>
<SessionTypesChart data={stats.sessionTypes ?? []} />
</div>
</div>
<div className="rounded-lg border border-border bg-canvas p-4">
<h3 className="mb-3 text-sm font-semibold text-primary">Daily Activity</h3>
<DailyActivityChart data={stats.dailyActivity ?? []} />
</div>
<div className="overflow-hidden rounded-lg border border-border bg-canvas">
<div className="border-b border-border-muted px-4 py-3 text-sm font-semibold text-primary">Recent Sessions</div>
<RecentSessions sessions={stats.recentSessions ?? []} />
</div>
</div>
);
}

View File

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

View File

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

View File

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

29
src/components/ui/Input.tsx Executable file
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,29 @@
'use client';
import { logoutAction } from '@/lib/actions/auth';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
export default function LogoutButton() {
const [, formAction] = useActionState(logoutAction, null);
return (
<form action={formAction}>
<Button type="submit" title="Logout" variant="ghost" size="logout">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</Button>
</form>
);
}

57
src/components/ui/Modal.tsx Executable file
View File

@@ -0,0 +1,57 @@
'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 bg-black/60 p-4">
<div className="absolute inset-0" onClick={onClose} aria-label="Close" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={cn(
'relative w-full max-w-md rounded-lg border border-border bg-canvas p-6 shadow-xl',
className
)}
>
<div className="mb-4 flex items-center justify-between">
<h3 id="modal-title" className="text-lg font-semibold text-primary">{title}</h3>
<button onClick={onClose} className="text-muted hover:text-primary" aria-label="Close modal">
×
</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,13 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
export default function RefreshButton() {
const router = useRouter();
return (
<Button variant="ghost" size="sm" onClick={() => router.refresh()}>
Refresh
</Button>
);
}

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

View File

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

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="border-l-4 border-yellow bg-yellow-bg px-4 py-3">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow"
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">
<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 border-t-transparent rounded-full mr-2"></div>
<span className="text-xs text-yellow">Downloading...</span>
</div>
</div>
</div>
</div>
);
}

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

53
src/lib/actions/auth.ts Executable file
View File

@@ -0,0 +1,53 @@
'use server';
import { redirect } from 'next/navigation';
import { loginUser, getOpenToken } from '@/lib/api/server/auth';
import { login, logout } from '@/lib/auth/server';
import { loginSchema, loginResponseSchema } from '../schemas';
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;
const loginData = loginSchema.safeParse({ username, password });
if (!loginData.success) {
return {
success: false,
message: loginData.error.message
};
}
const result = loginResponseSchema.safeParse(await loginUser(loginData.data));
if (result.success) {
const openToken = await getOpenToken(result.data.token);
await login(result.data.token, result.data.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() {
await logout();
}
export async function clearExpiredSessionAction() {
await logout();
}

261
src/lib/actions/configuration.ts Executable file
View File

@@ -0,0 +1,261 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import {
assistRulesSchema,
ConfigFile,
configurationsSchema,
eventConfigSchema,
eventRulesSchema,
serverSettingsSchema
} from '@/lib/schemas/config';
import type {
Configuration,
AssistRules,
Configurations,
EventConfig,
EventRules,
ServerSettings
} from '@/lib/schemas/config';
import { boolToInt } from '@/lib/utils';
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 rawConfig: 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)
};
const config = assistRulesSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.assistRules,
config.data,
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 rawConfig: 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)
};
const config = serverSettingsSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.settings,
config.data,
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 rawConfig: 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
};
const config = eventConfigSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.event,
config.data,
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 rawConfig: 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: boolToInt(formData.get('isRefuellingAllowedInRace') === 'true'),
isRefuellingTimeFixed: boolToInt(formData.get('isRefuellingTimeFixed') === 'true'),
isMandatoryPitstopRefuellingRequired:
boolToInt(formData.get('isMandatoryPitstopRefuellingRequired') === 'true'),
isMandatoryPitstopTyreChangeRequired:
boolToInt(formData.get('isMandatoryPitstopTyreChangeRequired') === 'true'),
isMandatoryPitstopSwapDriverRequired:
boolToInt(formData.get('isMandatoryPitstopSwapDriverRequired') === 'true'),
tyreSetCount: parseInt(formData.get('tyreSetCount') as string)
};
const config = eventRulesSchema.safeParse(rawConfig);
if (!config.success) {
return { success: false, message: config.error.message };
}
await updateServerConfiguration(
session.token!,
serverId,
ConfigFile.eventRules,
config.data,
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'
};
}
}
export async function bulkUpdateConfigurationsAction(
serverId: string,
configs: Configurations,
restart: boolean
) {
try {
const session = await requireAuth();
const validated = configurationsSchema.safeParse(configs);
if (!validated.success) {
return { success: false, message: validated.error.message };
}
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'All configurations updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update configurations'
};
}
}

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

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

48
src/lib/actions/membership.ts Executable file
View File

@@ -0,0 +1,48 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { createUser, deleteUser } from '@/lib/api/server/membership';
import { userCreateSchema } from '../schemas';
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;
const rawData = { username, password, role };
const data = userCreateSchema.safeParse(rawData);
if (!data.success) {
return { success: false, message: data.error.message };
}
await createUser(session.token!, data.data);
revalidatePath('/dashboard/membership');
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'
};
}
}

62
src/lib/actions/servers.ts Executable file
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);
}

63
src/lib/api/client/base.ts Executable file
View File

@@ -0,0 +1,63 @@
'use client';
import { SessionData } from '@/lib/session/config';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
export type ClientApiResponse<T> = {
data?: T;
error?: string;
message?: string;
};
const getSession = async (): Promise<SessionData | null> => {
const response = await fetch('/api/session');
if (response.ok) {
return await response.json();
}
return null;
};
export async function fetchClientAPI<T>(
endpoint: string,
method: string = 'GET',
body?: object,
customToken?: string
): Promise<ClientApiResponse<T>> {
let token = customToken;
let session: SessionData | null = null;
if (!token) {
session = await getSession();
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 = '/login?expired=true';
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() };
}

73
src/lib/api/client/hooks.ts Executable file
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'] });
}
});
}

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