Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c23a720d1 | |||
| cc4afd2294 | |||
| 2d85b63775 | |||
| 36c31fa790 | |||
| c7ef3e9d27 | |||
| 50d8662904 | |||
| 5a4457d369 | |||
| 4cc005d309 | |||
| 9d22027f23 | |||
| de41d2566c | |||
| 004ba49de3 | |||
| 3cf56b335c | |||
| b2f73a0b95 | |||
| 5360bb51c4 | |||
| 71fa228013 | |||
| 3c643d5dcc | |||
| c99eebde05 | |||
| 4f7705ec7c | |||
| 8a7299fba2 | |||
| db0b1491bd | |||
| 097d9d99fc | |||
| 14faaf87b7 | |||
| 9d240c3a80 | |||
| 374899e839 | |||
| da92ec72a7 | |||
| a5e50fe8b7 | |||
| 025c021896 | |||
| a4ff0a9339 | |||
| 45a67ebfb8 | |||
| 5a7485f654 | |||
| 710ac253e2 | |||
| 5c69ef716b | |||
| 1d13def6da | |||
| 2801f80b9a | |||
| 75d88257c8 | |||
| f8510c8db3 | |||
| e2f86fbcb6 | |||
| adf123f56a | |||
| 3ba68ac973 | |||
| 67a72f9a92 | |||
| 51c45ad4a9 | |||
| 61f0b69176 | |||
| 2976468c9f | |||
| d56a399e8e | |||
| 8dc8f98add | |||
| d8d008b5e8 | |||
| 51f45a806e | |||
| 5b8698cf81 | |||
| 69a3836f13 | |||
| 69d92a3fc9 | |||
| e8bf8498b8 | |||
|
|
f15a0175b8 | ||
|
|
996f1a1385 | ||
|
|
0cbc6935db | ||
|
|
6563396a83 | ||
|
|
c005090ab1 | ||
|
|
6aeb654abf | ||
|
|
3ba83ad538 | ||
|
|
fe6a36f3dc | ||
|
|
1ca98233f8 | ||
|
|
fe4d299eae | ||
|
|
b0ee67c2be | ||
|
|
b7999b02e8 | ||
|
|
4bc74f26d1 | ||
|
|
e6b7ec7401 | ||
|
|
373adcb49d | ||
|
|
8a5afee0e3 | ||
|
|
4888db7f1a | ||
|
|
4db5d49a64 | ||
|
|
bb0a5ab66d | ||
|
|
76d08df3da | ||
|
|
fac61ef678 | ||
|
|
55e0370004 | ||
|
|
9ce5802dc6 | ||
|
|
bc0964c758 | ||
|
|
b269144ee7 | ||
|
|
965e13a0bf |
52
.gitignore
vendored
Normal file → Executable file
52
.gitignore
vendored
Normal file → Executable 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
|
||||
|
||||
0
.prettierignore
Normal file → Executable file
0
.prettierignore
Normal file → Executable file
10
.prettierrc
Normal file → Executable file
10
.prettierrc
Normal file → Executable 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
40
Dockerfile
Normal file → Executable 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"]
|
||||
|
||||
9
LICENSE
9
LICENSE
@@ -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
52
README.md
Normal file → Executable 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.
|
||||
|
||||
@@ -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
25
eslint.config.mjs
Executable 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
7
next.config.ts
Executable 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
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
79
package.json
Normal file → Executable file
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"name": "acc-server-manager-web",
|
||||
"version": "0.24.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
};
|
||||
5
postcss.config.mjs
Executable file
5
postcss.config.mjs
Executable file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Executable file
1
public/file.svg
Executable 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
1
public/globe.svg
Executable 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
1
public/next.svg
Executable 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
1
public/vercel.svg
Executable 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
1
public/window.svg
Executable 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 |
@@ -1,54 +0,0 @@
|
||||
import { authStore } from '$stores/authStore';
|
||||
import { redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { redisSessionManager } from '$stores/redisSessionManager';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const BASE_URL = env.API_BASE_URL;
|
||||
|
||||
async function fetchAPI(endpoint: string, method: string = 'GET', body?: object, hdr?: object) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(hdr ?? {})
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status == 401) {
|
||||
authStore.set({
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
token: undefined,
|
||||
error: 'Wrong authorization key!'
|
||||
});
|
||||
redirect(303, '/login');
|
||||
}
|
||||
throw new Error(`API Error: ${response.statusText} - ${method} - ${endpoint}`);
|
||||
}
|
||||
|
||||
if (response.headers.get('Content-Type') == 'application/json') return response.json();
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function fetchAPIEvent(
|
||||
event: RequestEvent,
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: object
|
||||
) {
|
||||
if (!event.cookies) {
|
||||
redirect(308, '/login');
|
||||
}
|
||||
const session = await redisSessionManager.getSession(event.cookies);
|
||||
if (!session?.data?.token) {
|
||||
redirect(308, '/login');
|
||||
}
|
||||
|
||||
return fetchAPI(endpoint, method, body, { Authorization: `Bearer ${session.data.token}` });
|
||||
}
|
||||
|
||||
export default fetchAPI;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { fetchAPIEvent } from '$api/apiService';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { authStore } from '$stores/authStore';
|
||||
import { redisSessionManager } from '$stores/redisSessionManager';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const login = async (event: RequestEvent, username: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch(`${env.API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Invalid username or password.' }));
|
||||
authStore.set({
|
||||
token: undefined,
|
||||
error: errorData.error || 'Invalid username or password.'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const { token } = await response.json();
|
||||
|
||||
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
authStore.set({ token: undefined, error: 'Login failed. Please try again.' });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = (event: RequestEvent) => {
|
||||
return redisSessionManager.deleteCookie(event.cookies);
|
||||
};
|
||||
|
||||
export const checkAuth = async (event: RequestEvent) => {
|
||||
try {
|
||||
await fetchAPIEvent(event, '/api');
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { fetchAPIEvent } from '$api/apiService';
|
||||
import type { CarModel, CupCategory, DriverCategory, SessionType, Track } from '$models/lookups';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const getCarModels = async (event: RequestEvent): Promise<CarModel[]> => {
|
||||
return fetchAPIEvent(event, '/lookup/car-models');
|
||||
};
|
||||
|
||||
export const getCupCategories = async (event: RequestEvent): Promise<CupCategory[]> => {
|
||||
return fetchAPIEvent(event, '/lookup/cup-categories');
|
||||
};
|
||||
|
||||
export const getDriverCategories = async (event: RequestEvent): Promise<DriverCategory[]> => {
|
||||
return fetchAPIEvent(event, '/lookup/driver-categories');
|
||||
};
|
||||
|
||||
export const getSessionTypes = async (event: RequestEvent): Promise<SessionType[]> => {
|
||||
return fetchAPIEvent(event, '/lookup/session-types');
|
||||
};
|
||||
|
||||
export const getTracks = async (event: RequestEvent): Promise<Track[]> => {
|
||||
return fetchAPIEvent(event, '/lookup/tracks');
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import { fetchAPIEvent } from '$api/apiService';
|
||||
import type { User } from '$models/user';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export interface MembershipFilter {
|
||||
username?: string;
|
||||
role_name?: string;
|
||||
role_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedUsers {
|
||||
users: User[];
|
||||
pagination: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
roleId?: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const membershipService = {
|
||||
async getUsers(event: RequestEvent, filter?: MembershipFilter): Promise<User[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter) {
|
||||
if (filter.username) queryParams.append('username', filter.username);
|
||||
if (filter.role_name) queryParams.append('role_name', filter.role_name);
|
||||
if (filter.role_id) queryParams.append('role_id', filter.role_id);
|
||||
if (filter.page) queryParams.append('page', filter.page.toString());
|
||||
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
|
||||
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
|
||||
if (filter.sort_desc !== undefined)
|
||||
queryParams.append('sort_desc', filter.sort_desc.toString());
|
||||
}
|
||||
|
||||
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return await fetchAPIEvent(event, endpoint);
|
||||
},
|
||||
|
||||
async getUsersPaginated(event: RequestEvent, filter?: MembershipFilter): Promise<PaginatedUsers> {
|
||||
const users = await this.getUsers(event, filter);
|
||||
|
||||
const page = filter?.page || 1;
|
||||
const pageSize = filter?.page_size || 10;
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
const paginatedUsers = users.slice(startIndex, endIndex);
|
||||
const totalPages = Math.ceil(users.length / pageSize);
|
||||
|
||||
return {
|
||||
users: paginatedUsers,
|
||||
pagination: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total: users.length,
|
||||
total_pages: totalPages
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async getUser(event: RequestEvent, userId: string): Promise<User> {
|
||||
return await fetchAPIEvent(event, `/membership/${userId}`);
|
||||
},
|
||||
|
||||
async createUser(event: RequestEvent, userData: CreateUserRequest): Promise<User> {
|
||||
return await fetchAPIEvent(event, '/membership', 'POST', userData);
|
||||
},
|
||||
|
||||
async updateUser(
|
||||
event: RequestEvent,
|
||||
userId: string,
|
||||
userData: UpdateUserRequest
|
||||
): Promise<User> {
|
||||
return await fetchAPIEvent(event, `/membership/${userId}`, 'PUT', userData);
|
||||
},
|
||||
|
||||
async deleteUser(event: RequestEvent, userId: string): Promise<void> {
|
||||
await fetchAPIEvent(event, `/membership/${userId}`, 'DELETE');
|
||||
},
|
||||
|
||||
async getRoles(event: RequestEvent): Promise<Role[]> {
|
||||
return await fetchAPIEvent(event, '/membership/roles');
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side service for browser usage (not currently used)
|
||||
export const membershipClientService = {
|
||||
async getUsers(filter?: MembershipFilter): Promise<User[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter) {
|
||||
if (filter.username) queryParams.append('username', filter.username);
|
||||
if (filter.role_name) queryParams.append('role_name', filter.role_name);
|
||||
if (filter.role_id) queryParams.append('role_id', filter.role_id);
|
||||
if (filter.page) queryParams.append('page', filter.page.toString());
|
||||
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
|
||||
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
|
||||
if (filter.sort_desc !== undefined)
|
||||
queryParams.append('sort_desc', filter.sort_desc.toString());
|
||||
}
|
||||
|
||||
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch users: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async createUser(userData: CreateUserRequest): Promise<User> {
|
||||
const response = await fetch('/membership', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateUser(userId: string, userData: UpdateUserRequest): Promise<User> {
|
||||
const response = await fetch(`/membership/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
const response = await fetch(`/membership/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete user: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
async getRoles(): Promise<Role[]> {
|
||||
const response = await fetch('/membership/roles');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch roles: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { fetchAPIEvent } from '$api/apiService';
|
||||
import {
|
||||
configFile,
|
||||
type AssistRules,
|
||||
type Config,
|
||||
type ConfigFile,
|
||||
type Configuration,
|
||||
type Configurations,
|
||||
type EventConfig,
|
||||
type EventRules,
|
||||
type ServerSettings,
|
||||
type StateHistory,
|
||||
type StateHistoryStats
|
||||
} from '$models/config';
|
||||
import type { Server } from '$models/server';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const getServers = async (event: RequestEvent): Promise<Server[]> => {
|
||||
return fetchAPIEvent(event, '/server');
|
||||
};
|
||||
|
||||
export const getServerById = async (event: RequestEvent, serverId: string): Promise<Server> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}`);
|
||||
};
|
||||
|
||||
export const getConfigFiles = async (
|
||||
event: RequestEvent,
|
||||
serverId: string
|
||||
): Promise<Configurations> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config`);
|
||||
};
|
||||
|
||||
export const getConfigFile = async (
|
||||
event: RequestEvent,
|
||||
serverId: string,
|
||||
file: ConfigFile
|
||||
): Promise<Config> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${file}`);
|
||||
};
|
||||
|
||||
export const getStateHistory = async (
|
||||
event: RequestEvent,
|
||||
serverId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<Array<StateHistory>> => {
|
||||
return fetchAPIEvent(
|
||||
event,
|
||||
`/server/${serverId}/state-history?start_date=${startDate}&end_date=${endDate}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getStateHistoryStats = async (
|
||||
event: RequestEvent,
|
||||
serverId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<StateHistoryStats> => {
|
||||
return fetchAPIEvent(
|
||||
event,
|
||||
`/server/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getEventFile = async (event: RequestEvent, serverId: string): Promise<EventConfig> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.event}`);
|
||||
};
|
||||
|
||||
export const getConfigurationFile = async (
|
||||
event: RequestEvent,
|
||||
serverId: string
|
||||
): Promise<Configuration> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.configuration}`);
|
||||
};
|
||||
|
||||
export const getAssistRulesFile = async (
|
||||
event: RequestEvent,
|
||||
serverId: string
|
||||
): Promise<AssistRules> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.assistRules}`);
|
||||
};
|
||||
|
||||
export const getEventRulesFile = async (
|
||||
event: RequestEvent,
|
||||
serverId: string
|
||||
): Promise<EventRules> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.eventRules}`);
|
||||
};
|
||||
|
||||
export const getSettingsFile = async (
|
||||
event: RequestEvent,
|
||||
serverId: string
|
||||
): Promise<ServerSettings> => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/config/${configFile.settings}`);
|
||||
};
|
||||
|
||||
export const updateConfig = async (
|
||||
event: RequestEvent,
|
||||
serverId: string,
|
||||
file: ConfigFile,
|
||||
newConfig?: Config,
|
||||
override: boolean | string = false,
|
||||
restart: boolean | string = true
|
||||
) => {
|
||||
return fetchAPIEvent(
|
||||
event,
|
||||
`/server/${serverId}/config/${file}?override=${override}&restart=${restart}`,
|
||||
'PUT',
|
||||
newConfig
|
||||
);
|
||||
};
|
||||
|
||||
export const restartService = async (event: RequestEvent, serverId: string) => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/service/restart`, 'POST');
|
||||
};
|
||||
|
||||
export const startService = async (event: RequestEvent, serverId: string) => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/service/start`, 'POST');
|
||||
};
|
||||
|
||||
export const stopService = async (event: RequestEvent, serverId: string) => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/service/stop`, 'POST');
|
||||
};
|
||||
|
||||
export const getServiceStatus = async (event: RequestEvent, serverId: string) => {
|
||||
return fetchAPIEvent(event, `/server/${serverId}/service`);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import './styles/button.css';
|
||||
@import './styles/loader.css';
|
||||
@import './styles/inputs.css';
|
||||
15
src/app.d.ts
vendored
15
src/app.d.ts
vendored
@@ -1,15 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user: import('$models/user').User | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
src/app.html
12
src/app.html
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
16
src/app/dashboard/activity-log/page.tsx
Normal file
16
src/app/dashboard/activity-log/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { getServers } from '@/lib/api/server/servers';
|
||||
import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
|
||||
|
||||
export default async function ActivityLogPage() {
|
||||
const session = await requireAuth();
|
||||
const servers = await getServers(session.token!);
|
||||
const serverOptions = servers.map((s) => ({ id: s.id, name: s.name }));
|
||||
|
||||
return (
|
||||
<main className="p-5">
|
||||
<h1 className="mb-5 text-lg font-semibold text-primary">Activity Log</h1>
|
||||
<ActivityLogTable servers={serverOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
src/app/dashboard/layout.tsx
Executable file
32
src/app/dashboard/layout.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
38
src/app/dashboard/membership/page.tsx
Executable file
38
src/app/dashboard/membership/page.tsx
Executable 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
16
src/app/dashboard/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
41
src/app/dashboard/server/[id]/page.tsx
Executable file
41
src/app/dashboard/server/[id]/page.tsx
Executable 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
35
src/app/globals.css
Executable file
35
src/app/globals.css
Executable 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
19
src/app/layout.tsx
Executable 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
18
src/app/login/page.tsx
Executable 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
12
src/app/page.tsx
Executable file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSession } from '@/lib/auth/server';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (session.token && session.user) {
|
||||
redirect('/dashboard');
|
||||
} else {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type AssistRules } from '$models/config';
|
||||
|
||||
const { config, id }: { config: AssistRules; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.assistRules} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Stability Control Level Max -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Stability Control Level Max:
|
||||
<input
|
||||
bind:value={editedConfig.stabilityControlLevelMax}
|
||||
disabled={formLoading}
|
||||
name="stabilityControlLevelMax"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Autosteer -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Autosteer:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutosteer}
|
||||
disabled={formLoading}
|
||||
name="disableAutosteer"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Lights -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Lights:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoLights}
|
||||
disabled={formLoading}
|
||||
name="disableAutoLights"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Wiper -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Wiper:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoWiper}
|
||||
disabled={formLoading}
|
||||
name="disableAutoWiper"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Engine Start -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Engine Start:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoEngineStart}
|
||||
disabled={formLoading}
|
||||
name="disableAutoEngineStart"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Pit Limiter -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Pit Limiter:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoPitLimiter}
|
||||
disabled={formLoading}
|
||||
name="disableAutoPitLimiter"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Gear -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Gear:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoGear}
|
||||
disabled={formLoading}
|
||||
name="disableAutoGear"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Clutch -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Clutch:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoClutch}
|
||||
disabled={formLoading}
|
||||
name="disableAutoClutch"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Ideal Line -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Ideal Line:
|
||||
<select
|
||||
bind:value={editedConfig.disableIdealLine}
|
||||
disabled={formLoading}
|
||||
name="disableIdealLine"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={restart}
|
||||
name="restart"
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type Configuration } from '$models/config';
|
||||
|
||||
const { config, id }: { config: Configuration; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.configuration} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
UDP Port:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="udpPort"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.udpPort}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
TCP Port:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="tcpPort"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.tcpPort}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Connections:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="maxConnections"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.maxConnections}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Lan Discovery:
|
||||
<select
|
||||
bind:value={editedConfig.lanDiscovery}
|
||||
disabled={formLoading}
|
||||
name="lanDiscovery"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Register To Lobby:
|
||||
<select
|
||||
bind:value={editedConfig.registerToLobby}
|
||||
disabled={formLoading}
|
||||
name="registerToLobby"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input type="hidden" name="configVersion" value={1} />
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,328 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type EventConfig } from '$models/config';
|
||||
import type { Track } from '$models/lookups';
|
||||
|
||||
const { config, tracks, id }: { config: EventConfig; tracks: Track[]; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
if (!editedConfig.sessions) editedConfig.sessions = [];
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.event} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Track:
|
||||
<select
|
||||
bind:value={editedConfig.track}
|
||||
disabled={formLoading}
|
||||
name="track"
|
||||
class="form form-select"
|
||||
>
|
||||
{#each tracks as track}
|
||||
<option value={track.track}>{track.track}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Pre-Race waiting time seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="preRaceWaitingTimeSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.preRaceWaitingTimeSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session over time seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="sessionOverTimeSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.sessionOverTimeSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Ambient temp:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="ambientTemp"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.ambientTemp}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Cloud level:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="cloudLevel"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.cloudLevel}
|
||||
step=".01"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Rain:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="rain"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.rain}
|
||||
step=".01"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Weather randomness:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="weatherRandomness"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.weatherRandomness}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Post-Qualy seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="postQualySeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.postQualySeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Post-Race seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="postRaceSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.postRaceSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Simracer weather conditions:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="simracerWeatherConditions"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.simracerWeatherConditions}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is fixed condition qualification:
|
||||
<select
|
||||
bind:value={editedConfig.isFixedConditionQualification}
|
||||
disabled={formLoading}
|
||||
name="isFixedConditionQualification"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Sessions:
|
||||
<div class="mt-2 space-y-4">
|
||||
{#each editedConfig.sessions as session, index}
|
||||
<div class="mb-4 rounded-lg border border-gray-700 bg-gray-800 p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Hour of Day -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300"
|
||||
>Hour of Day:
|
||||
<input
|
||||
bind:value={session.hourOfDay}
|
||||
name={`sessions[${index}].hourOfDay`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Day of Weekend -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Day of Weekend:
|
||||
<input
|
||||
bind:value={session.dayOfWeekend}
|
||||
name={`sessions[${index}].dayOfWeekend`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Time Multiplier -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Time Multiplier:
|
||||
<input
|
||||
bind:value={session.timeMultiplier}
|
||||
name={`sessions[${index}].timeMultiplier`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Session Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session Type:
|
||||
<select
|
||||
bind:value={session.sessionType}
|
||||
name={`sessions[${index}].sessionType`}
|
||||
disabled={formLoading}
|
||||
class="form form-select"
|
||||
>
|
||||
<option value="P">Practice</option>
|
||||
<option value="Q">Qualifying</option>
|
||||
<option value="R">Race</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Session Duration Minutes -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session Duration (Minutes):
|
||||
<input
|
||||
bind:value={session.sessionDurationMinutes}
|
||||
name={`sessions[${index}].sessionDurationMinutes`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editedConfig.sessions = editedConfig.sessions.filter((_, i) => i !== index);
|
||||
}}
|
||||
class="mt-4 flex items-center rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Remove Session
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editedConfig.sessions = [
|
||||
...editedConfig.sessions,
|
||||
{
|
||||
hourOfDay: 14,
|
||||
dayOfWeekend: 1,
|
||||
timeMultiplier: 1,
|
||||
sessionType: 'Practice',
|
||||
sessionDurationMinutes: 60
|
||||
}
|
||||
];
|
||||
}}
|
||||
class="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Session
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,213 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type EventRules } from '$models/config';
|
||||
|
||||
const { config, id }: { config: EventRules; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.eventRules} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Qualify Standing Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Qualify Standing Type:
|
||||
<input
|
||||
bind:value={editedConfig.qualifyStandingType}
|
||||
disabled={formLoading}
|
||||
name="qualifyStandingType"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Pit Window Length Sec -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Pit Window Length (Sec):
|
||||
<input
|
||||
bind:value={editedConfig.pitWindowLengthSec}
|
||||
disabled={formLoading}
|
||||
name="pitWindowLengthSec"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Driver Stint Time Sec -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Driver Stint Time (Sec):
|
||||
<input
|
||||
bind:value={editedConfig.driverStintTimeSec}
|
||||
disabled={formLoading}
|
||||
name="driverStintTimeSec"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mandatory Pitstop Count -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Mandatory Pitstop Count:
|
||||
<input
|
||||
bind:value={editedConfig.mandatoryPitstopCount}
|
||||
disabled={formLoading}
|
||||
name="mandatoryPitstopCount"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max Total Driving Time -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Total Driving Time:
|
||||
<input
|
||||
bind:value={editedConfig.maxTotalDrivingTime}
|
||||
disabled={formLoading}
|
||||
name="maxTotalDrivingTime"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Refuelling Allowed In Race -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Refuelling Allowed In Race:
|
||||
<select
|
||||
bind:value={editedConfig.isRefuellingAllowedInRace}
|
||||
disabled={formLoading}
|
||||
name="isRefuellingAllowedInRace"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Refuelling Time Fixed -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Refuelling Time Fixed:
|
||||
<select
|
||||
bind:value={editedConfig.isRefuellingTimeFixed}
|
||||
disabled={formLoading}
|
||||
name="isRefuellingTimeFixed"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Refuelling Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Refuelling Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopRefuellingRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopRefuellingRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Tyre Change Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Tyre Change Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopTyreChangeRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopTyreChangeRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Swap Driver Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Swap Driver Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopSwapDriverRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopSwapDriverRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tyre Set Count -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Tyre Set Count:
|
||||
<input
|
||||
bind:value={editedConfig.tyreSetCount}
|
||||
disabled={formLoading}
|
||||
name="tyreSetCount"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,306 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type ServerSettings } from '$models/config';
|
||||
|
||||
const { config, id }: { config: ServerSettings; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.settings} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Server Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Server Name:
|
||||
<input
|
||||
bind:value={editedConfig.serverName}
|
||||
disabled={formLoading}
|
||||
name="serverName"
|
||||
type="text"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Admin Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Admin Password:
|
||||
<input
|
||||
bind:value={editedConfig.adminPassword}
|
||||
disabled={formLoading}
|
||||
name="adminPassword"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Car Group -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Car Group:
|
||||
<select
|
||||
bind:value={editedConfig.carGroup}
|
||||
disabled={formLoading}
|
||||
name="carGroup"
|
||||
class="form form-select"
|
||||
>
|
||||
{#each carGroups as group}
|
||||
<option value={group}>{group}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Track Medals Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Track Medals Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.trackMedalsRequirement}
|
||||
disabled={formLoading}
|
||||
name="trackMedalsRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Safety Rating Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Safety Rating Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.safetyRatingRequirement}
|
||||
disabled={formLoading}
|
||||
name="safetyRatingRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Racecraft Rating Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Racecraft Rating Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.racecraftRatingRequirement}
|
||||
disabled={formLoading}
|
||||
name="racecraftRatingRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Password:
|
||||
<input
|
||||
bind:value={editedConfig.password}
|
||||
disabled={formLoading}
|
||||
name="password"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Spectator Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Spectator Password:
|
||||
<input
|
||||
bind:value={editedConfig.spectatorPassword}
|
||||
disabled={formLoading}
|
||||
name="spectatorPassword"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max Car Slots -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Car Slots:
|
||||
<input
|
||||
bind:value={editedConfig.maxCarSlots}
|
||||
disabled={formLoading}
|
||||
name="maxCarSlots"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Dump Leaderboards -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Dump Leaderboards:
|
||||
<select
|
||||
bind:value={editedConfig.dumpLeaderboards}
|
||||
disabled={formLoading}
|
||||
name="dumpLeaderboards"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Race Locked -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Race Locked:
|
||||
<select
|
||||
bind:value={editedConfig.isRaceLocked}
|
||||
disabled={formLoading}
|
||||
name="isRaceLocked"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Randomize Track When Empty -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Randomize Track When Empty:
|
||||
<select
|
||||
bind:value={editedConfig.randomizeTrackWhenEmpty}
|
||||
disabled={formLoading}
|
||||
name="randomizeTrackWhenEmpty"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Central Entry List Path -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Central Entry List Path:
|
||||
<input
|
||||
bind:value={editedConfig.centralEntryListPath}
|
||||
disabled={formLoading}
|
||||
name="centralEntryListPath"
|
||||
type="text"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Allow Auto DQ -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Allow Auto DQ:
|
||||
<select
|
||||
bind:value={editedConfig.allowAutoDQ}
|
||||
disabled={formLoading}
|
||||
name="allowAutoDQ"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Short Formation Lap -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Short Formation Lap:
|
||||
<select
|
||||
bind:value={editedConfig.shortFormationLap}
|
||||
disabled={formLoading}
|
||||
name="shortFormationLap"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Formation Lap Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Formation Lap Type:
|
||||
<select
|
||||
bind:value={editedConfig.formationLapType}
|
||||
disabled={formLoading}
|
||||
name="formationLapType"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>Old Limiter Lap</option>
|
||||
<option value={1}>Free (replaces /manual start), only usable for private servers</option>
|
||||
<option value={3}>Default formation lap with position control and UI</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ignore Premature Disconnects -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Ignore Premature Disconnects:
|
||||
<select
|
||||
bind:value={editedConfig.ignorePrematureDisconnects}
|
||||
disabled={formLoading}
|
||||
name="ignorePrematureDisconnects"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={restart}
|
||||
name="restart"
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let currentPage: number = 1;
|
||||
export let totalPages: number = 1;
|
||||
export let onPageChange: (page: number) => void = () => {};
|
||||
|
||||
$: pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
function generatePageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (current <= 4) {
|
||||
// Show first 5 pages, then ellipsis, then last page
|
||||
pages.push(...[1, 2, 3, 4, 5]);
|
||||
if (total > 6) pages.push('...');
|
||||
pages.push(total);
|
||||
} else if (current >= total - 3) {
|
||||
// Show first page, ellipsis, then last 5 pages
|
||||
pages.push(1);
|
||||
if (total > 6) pages.push('...');
|
||||
pages.push(...[total - 4, total - 3, total - 2, total - 1, total]);
|
||||
} else {
|
||||
// Show first page, ellipsis, current and neighbors, ellipsis, last page
|
||||
pages.push(1, '...', current - 1, current, current + 1, '...', total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function handlePageClick(page: number | string) {
|
||||
if (typeof page === 'number' && page !== currentPage) {
|
||||
onPageChange(page);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (currentPage < totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center space-x-1">
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === 1
|
||||
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
disabled={currentPage === 1}
|
||||
onclick={handlePrevious}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<!-- Page numbers -->
|
||||
{#each pages as page}
|
||||
{#if page === '...'}
|
||||
<span class="px-3 py-2 text-sm text-gray-500">...</span>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
onclick={() => handlePageClick(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Next button -->
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === totalPages
|
||||
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={handleNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page info -->
|
||||
<div class="mt-2 text-center text-sm text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Server } from '$models/server';
|
||||
import { getStatusColor, ServiceStatus, serviceStatusToString } from '$lib/types/serviceStatus';
|
||||
|
||||
let { server }: { server: Server } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
|
||||
<a href={`dashboard/server/${server.id}`}>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">{server.name}</h3>
|
||||
<div class="mt-1 flex items-center">
|
||||
<span class={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
|
||||
></span>
|
||||
<span class="text-sm capitalize">{serviceStatusToString(server.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
|
||||
<div>
|
||||
<span class="text-gray-500">Track:</span>
|
||||
{server.state?.track}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Players:</span>
|
||||
{server.state?.playerCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<form method="POST" action="?/start">
|
||||
<div class="flex justify-between bg-gray-900 px-4 py-3">
|
||||
<input type="hidden" name="id" value={server.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={server.status === ServiceStatus.Running}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-green-600 px-3 py-1 text-xs font-medium hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
disabled={server.status === ServiceStatus.Stopped}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
formaction="?/restart"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
disabled={server.status === ServiceStatus.Stopped}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-red-600 px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
formaction="?/stop"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,346 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Chart from 'chart.js/auto';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { compareAsc, isSameDay } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import type { StateHistoryStats } from '$models/config';
|
||||
import { flatMap } from 'lodash-es';
|
||||
|
||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
interface Props {
|
||||
// Props
|
||||
statistics: StateHistoryStats;
|
||||
}
|
||||
|
||||
let { statistics }: Props = $props();
|
||||
|
||||
// Chart instances
|
||||
let playerCountChart: Chart | null = null;
|
||||
let sessionTypeChart: Chart | null = null;
|
||||
let dailyActivityChart: Chart | null = null;
|
||||
|
||||
// Chart canvas elements
|
||||
let playerCountCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let sessionTypeCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let dailyActivityCanvas: HTMLCanvasElement | undefined = $state();
|
||||
|
||||
// Initialize date range (last 30 days by default)
|
||||
onMount(() => {
|
||||
createCharts();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Cleanup charts
|
||||
if (playerCountChart) playerCountChart.destroy();
|
||||
if (sessionTypeChart) sessionTypeChart.destroy();
|
||||
if (dailyActivityChart) dailyActivityChart.destroy();
|
||||
});
|
||||
|
||||
function createCharts() {
|
||||
if (!statistics || !playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
|
||||
|
||||
playerCountChart = new Chart(playerCountCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: statistics.playerCountOverTime.map(({ timestamp }) =>
|
||||
formatDate(timestamp, 'MMM dd kk:mm')
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Player Count',
|
||||
data: statistics.playerCountOverTime.map(({ count }) => count),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sessionTypeChart = new Chart(sessionTypeCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: statistics.sessionTypes.map(({ name }) => name),
|
||||
datasets: [
|
||||
{
|
||||
data: statistics.sessionTypes.map(({ count }) => count),
|
||||
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dailyActivityChart = new Chart(dailyActivityCanvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: statistics.dailyActivity.map(({ date }) => date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Sessions',
|
||||
data: statistics.dailyActivity.map(({ sessionsCount }) => sessionsCount),
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(dateString: string, formatString: string) {
|
||||
return formatInTimeZone(dateString, localTimeZone, formatString, {
|
||||
timeZone: 'utc'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-green-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Average Players</p>
|
||||
<p class="text-2xl font-bold text-white">{Math.round(statistics.averagePlayers)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-yellow-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Peak Players</p>
|
||||
<p class="text-2xl font-bold text-white">{statistics.peakPlayers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-blue-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
|
||||
<p class="text-2xl font-bold text-white">{statistics.totalSessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-purple-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
|
||||
<p class="text-2xl font-bold text-white">{formatDuration(statistics.totalPlaytime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Player Count Over Time -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Player Count Over Time</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={playerCountCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Types Pie Chart -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Session Types</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={sessionTypeCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Activity Bar Chart -->
|
||||
<div class="mb-8 rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Daily Activity</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={dailyActivityCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session History Table -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Recent Sessions</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Date</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Type</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Track</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Duration</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Players</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||
{#each statistics.recentSessions as session}
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDate(session.date, 'MMM dd kk:mm')}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
||||
session.type === 'Race'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: session.type === 'Qualifying'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}
|
||||
>
|
||||
{session.type}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.track}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDuration(session.duration)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.players}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { message = '', type = 'success', duration = 5000 } = $props();
|
||||
|
||||
let visible = $state(true);
|
||||
|
||||
let timer: number;
|
||||
|
||||
onMount(() => {
|
||||
timer = window.setTimeout(() => {
|
||||
visible = false;
|
||||
}, duration);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
const baseClasses =
|
||||
'fixed bottom-5 right-5 p-4 rounded-lg shadow-lg text-white transition-all duration-500 ease-in-out transform';
|
||||
const typeClasses = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600'
|
||||
};
|
||||
const visibilityClasses = visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5';
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="{baseClasses} {typeClasses[type]} {visibilityClasses}" role="alert">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
256
src/components/activityLog/ActivityLogTable.tsx
Normal file
256
src/components/activityLog/ActivityLogTable.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getActivityLogAction, getGlobalActivityLogAction } from '@/lib/actions/activityLog';
|
||||
import type { ActivityLog, ActivityLogFilter, ActionType } from '@/lib/schemas/activityLog';
|
||||
import { ACTION_TYPE_LABELS, actionTypeSchema } from '@/lib/schemas/activityLog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
interface ServerOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ActivityLogTableProps {
|
||||
serverId?: string;
|
||||
servers?: ServerOption[];
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function parseDetails(raw: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (obj.file && Array.isArray(obj.changed_keys)) {
|
||||
return `Updated ${obj.file} — changed: ${(obj.changed_keys as string[]).join(', ')}`;
|
||||
}
|
||||
if (obj.service) {
|
||||
return `Service: ${obj.service}`;
|
||||
}
|
||||
if (obj.driver_count !== undefined && obj.race_count !== undefined) {
|
||||
return `${obj.driver_count} drivers, ${obj.race_count} races`;
|
||||
}
|
||||
return raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_BADGE: Record<ActionType, string> = {
|
||||
config_update: 'bg-blue-bg text-blue',
|
||||
server_start: 'bg-green-bg text-green',
|
||||
server_stop: 'bg-red-bg text-red',
|
||||
server_restart: 'bg-yellow-bg text-yellow',
|
||||
leaderboard_update: 'bg-overlay text-muted'
|
||||
};
|
||||
|
||||
export function ActivityLogTable({ serverId, servers }: ActivityLogTableProps) {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filterAction, setFilterAction] = useState<ActionType | ''>('');
|
||||
const [filterServerId, setFilterServerId] = useState('');
|
||||
const [filterStartDate, setFilterStartDate] = useState('');
|
||||
const [filterEndDate, setFilterEndDate] = useState('');
|
||||
|
||||
const [appliedFilter, setAppliedFilter] = useState<ActivityLogFilter>({});
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (currentPage: number, filter: ActivityLogFilter, reset: boolean) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const params = { ...filter, page: currentPage, page_size: PAGE_SIZE };
|
||||
if (serverId) params.server_id = serverId;
|
||||
const result = await getActivityLogAction(params);
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
setHasMore(result.data.length === PAGE_SIZE);
|
||||
setLogs((prev) => (reset ? result.data : [...prev, ...result.data]));
|
||||
},
|
||||
[serverId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(1, {}, true);
|
||||
}, [fetchLogs]);
|
||||
|
||||
const applyFilters = () => {
|
||||
const filter: ActivityLogFilter = {};
|
||||
if (filterAction) filter.action = filterAction as ActionType;
|
||||
if (filterServerId) filter.server_id = filterServerId;
|
||||
if (filterStartDate) filter.start_date = new Date(filterStartDate).toISOString();
|
||||
if (filterEndDate) filter.end_date = new Date(filterEndDate).toISOString();
|
||||
setAppliedFilter(filter);
|
||||
setPage(1);
|
||||
setLogs([]);
|
||||
fetchLogs(1, filter, true);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterAction('');
|
||||
setFilterServerId('');
|
||||
setFilterStartDate('');
|
||||
setFilterEndDate('');
|
||||
setAppliedFilter({});
|
||||
setPage(1);
|
||||
setLogs([]);
|
||||
fetchLogs(1, {}, true);
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
fetchLogs(next, appliedFilter, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-canvas flex flex-wrap items-end gap-3 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-subtle text-xs">Action type</label>
|
||||
<select
|
||||
value={filterAction}
|
||||
onChange={(e) => setFilterAction(e.target.value as ActionType | '')}
|
||||
className="form-input w-44 text-sm"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
{actionTypeSchema.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{ACTION_TYPE_LABELS[opt]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{servers && servers.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-subtle text-xs">Server</label>
|
||||
<select
|
||||
value={filterServerId}
|
||||
onChange={(e) => setFilterServerId(e.target.value)}
|
||||
className="form-input w-48 text-sm"
|
||||
>
|
||||
<option value="">All servers</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-subtle text-xs">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterStartDate}
|
||||
onChange={(e) => setFilterStartDate(e.target.value)}
|
||||
className="form-input w-36 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-subtle text-xs">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterEndDate}
|
||||
onChange={(e) => setFilterEndDate(e.target.value)}
|
||||
className="form-input w-36 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={applyFilters}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red text-sm">{error}</p>}
|
||||
|
||||
<div className="border-border bg-canvas overflow-hidden rounded-lg border">
|
||||
{logs.length === 0 && !isLoading ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<div className="mb-3 text-4xl">📋</div>
|
||||
<p className="text-muted text-sm">No activity logged yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-border-muted bg-base border-b">
|
||||
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
|
||||
Time
|
||||
</th>
|
||||
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
|
||||
User
|
||||
</th>
|
||||
{!serverId && (
|
||||
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
|
||||
Server
|
||||
</th>
|
||||
)}
|
||||
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th className="text-subtle px-4 py-2 text-left text-xs font-medium tracking-wider uppercase">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-border-muted hover:bg-hover border-b transition-colors last:border-0"
|
||||
>
|
||||
<td className="text-muted px-4 py-3 text-sm whitespace-nowrap">
|
||||
{formatDate(log.createdAt)}
|
||||
</td>
|
||||
<td className="text-secondary px-4 py-3 text-sm">{log.username}</td>
|
||||
{!serverId && (
|
||||
<td className="text-secondary px-4 py-3 text-sm">
|
||||
{log.server?.name ?? log.serverId}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_BADGE[log.action] ?? 'bg-canvas text-muted'}`}
|
||||
>
|
||||
{ACTION_TYPE_LABELS[log.action] ?? log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted px-4 py-3 text-sm">{parseDetails(log.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="text-muted px-4 py-4 text-center text-sm">Loading…</div>}
|
||||
|
||||
{hasMore && !isLoading && logs.length > 0 && (
|
||||
<div className="border-border-muted border-t px-4 py-3 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={loadMore}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/configuration/AssistRulesEditor.tsx
Executable file
99
src/components/configuration/AssistRulesEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
83
src/components/configuration/ConfigurationEditor.tsx
Executable file
83
src/components/configuration/ConfigurationEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
369
src/components/configuration/EventConfigEditor.tsx
Executable file
369
src/components/configuration/EventConfigEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
120
src/components/configuration/EventRulesEditor.tsx
Executable file
120
src/components/configuration/EventRulesEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
195
src/components/configuration/ServerSettingsEditor.tsx
Executable file
195
src/components/configuration/ServerSettingsEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
192
src/components/leaderboard/LeaderboardManager.tsx
Executable file
192
src/components/leaderboard/LeaderboardManager.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
112
src/components/leaderboard/LeaderboardStandings.tsx
Executable file
112
src/components/leaderboard/LeaderboardStandings.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
126
src/components/leaderboard/editors/DriversEditor.tsx
Executable file
126
src/components/leaderboard/editors/DriversEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
98
src/components/leaderboard/editors/FlPointsEditor.tsx
Executable file
98
src/components/leaderboard/editors/FlPointsEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
129
src/components/leaderboard/editors/PointsTableEditor.tsx
Executable file
129
src/components/leaderboard/editors/PointsTableEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
154
src/components/leaderboard/editors/TracksEditor.tsx
Executable file
154
src/components/leaderboard/editors/TracksEditor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
93
src/components/login/LoginForm.tsx
Executable file
93
src/components/login/LoginForm.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
131
src/components/membership/CreateUserModal.tsx
Executable file
131
src/components/membership/CreateUserModal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
69
src/components/membership/DeleteUserModal.tsx
Executable file
69
src/components/membership/DeleteUserModal.tsx
Executable 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 "{user.username}"? 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>
|
||||
);
|
||||
}
|
||||
217
src/components/membership/UserManagementTable.tsx
Executable file
217
src/components/membership/UserManagementTable.tsx
Executable 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/components/providers/QueryProvider.tsx
Executable file
13
src/components/providers/QueryProvider.tsx
Executable 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>;
|
||||
}
|
||||
111
src/components/server/CreateServerModal.tsx
Executable file
111
src/components/server/CreateServerModal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
82
src/components/server/DeleteServerModal.tsx
Executable file
82
src/components/server/DeleteServerModal.tsx
Executable 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>"{server.name}"</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>
|
||||
);
|
||||
}
|
||||
120
src/components/server/ServerCard.tsx
Executable file
120
src/components/server/ServerCard.tsx
Executable file
@@ -0,0 +1,120 @@
|
||||
'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 flex items-center justify-between gap-2">
|
||||
<Badge variant={statusBadgeVariant(server.status)}>
|
||||
{serviceStatusToString(server.status)}
|
||||
</Badge>
|
||||
{server.lastActivity && (
|
||||
<span className="truncate text-xs text-subtle">
|
||||
by {server.lastActivity.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-1.5 border-t border-border-muted bg-base px-4 py-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={startServer}
|
||||
disabled={isRunning || disabled || isPending}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={restartServer}
|
||||
disabled={!isRunning || disabled || isPending}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
size="sm"
|
||||
onClick={stopServer}
|
||||
disabled={!isRunning || disabled || isPending}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
src/components/server/ServerConfigurationTabs.tsx
Executable file
218
src/components/server/ServerConfigurationTabs.tsx
Executable file
@@ -0,0 +1,218 @@
|
||||
'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 { ActivityLogTable } from '@/components/activityLog/ActivityLogTable';
|
||||
import { ServerLogViewer } from '@/components/server/ServerLogViewer';
|
||||
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: '🏆' },
|
||||
{ id: ServerTab.activityLog, name: 'History', icon: '📋' },
|
||||
{ id: ServerTab.logs, name: 'Logs', icon: '📄' }
|
||||
];
|
||||
|
||||
const configTabs = new Set([
|
||||
ServerTab.configuration,
|
||||
ServerTab.assistRules,
|
||||
ServerTab.event,
|
||||
ServerTab.eventRules,
|
||||
ServerTab.settings
|
||||
]);
|
||||
|
||||
export function ServerConfigurationTabs({
|
||||
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} />;
|
||||
|
||||
case ServerTab.activityLog:
|
||||
return <ActivityLogTable serverId={serverId} />;
|
||||
|
||||
case ServerTab.logs:
|
||||
return <ServerLogViewer serverId={serverId} />;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
477
src/components/server/ServerCreationPopup.tsx
Executable file
477
src/components/server/ServerCreationPopup.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
36
src/components/server/ServerCreationPopupContainer.tsx
Executable file
36
src/components/server/ServerCreationPopupContainer.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
93
src/components/server/ServerHeader.tsx
Executable file
93
src/components/server/ServerHeader.tsx
Executable 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/components/server/ServerListWithActions.tsx
Executable file
80
src/components/server/ServerListWithActions.tsx
Executable 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
src/components/server/ServerLogViewer.tsx
Normal file
153
src/components/server/ServerLogViewer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useWebSocket } from '@/lib/websocket/context';
|
||||
import type { WebSocketMessage } from '@/lib/schemas/websocket';
|
||||
import { getServerLogAction } from '@/lib/actions/serverLog';
|
||||
|
||||
const MAX_BUFFER = 5000;
|
||||
const LINE_OPTIONS = [100, 500, 1000, 5000] as const;
|
||||
|
||||
interface ServerLogViewerProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ServerLogViewer({ serverId }: ServerLogViewerProps) {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [initialLines, setInitialLines] = useState<number>(100);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
const { associateWithServer, addMessageHandler, removeMessageHandler } = useWebSocket();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isPausedRef = useRef(false);
|
||||
|
||||
const appendLines = useCallback((newLines: string[]) => {
|
||||
setLines((prev) => {
|
||||
const combined = [...prev, ...newLines];
|
||||
return combined.length > MAX_BUFFER ? combined.slice(combined.length - MAX_BUFFER) : combined;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchInitial = useCallback(
|
||||
async (count: number) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getServerLogAction(serverId, count);
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
setLines(result.lines);
|
||||
},
|
||||
[serverId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
associateWithServer(serverId);
|
||||
fetchInitial(initialLines);
|
||||
}, [serverId, associateWithServer, fetchInitial, initialLines]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: WebSocketMessage) => {
|
||||
if (message.type !== 'log_line') return;
|
||||
if (message.server_id !== serverId) return;
|
||||
appendLines([message.data.line]);
|
||||
},
|
||||
[serverId, appendLines]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
addMessageHandler(handleMessage);
|
||||
return () => {
|
||||
removeMessageHandler(handleMessage);
|
||||
};
|
||||
}, [addMessageHandler, removeMessageHandler, handleMessage]);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive, unless paused
|
||||
useEffect(() => {
|
||||
if (!isPausedRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 40;
|
||||
const shouldPause = !isAtBottom;
|
||||
isPausedRef.current = shouldPause;
|
||||
setIsPaused(shouldPause);
|
||||
};
|
||||
|
||||
const jumpToBottom = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
isPausedRef.current = false;
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const handleLineLimitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const count = Number(e.target.value);
|
||||
setInitialLines(count);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-subtle">Initial lines</label>
|
||||
<select
|
||||
value={initialLines}
|
||||
onChange={handleLineLimitChange}
|
||||
className="form-input w-28 text-sm"
|
||||
>
|
||||
{LINE_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-auto text-xs text-subtle">{lines.length} lines buffered</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red">{error}</p>}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[calc(100vh-280px)] min-h-64 overflow-y-auto rounded-lg border border-border bg-base p-3 font-mono text-xs text-secondary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="text-muted">Loading…</span>
|
||||
) : lines.length === 0 ? (
|
||||
<span className="text-muted">No log output yet.</span>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} className="leading-5 whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPaused && (
|
||||
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-overlay px-3 py-1.5 shadow-lg">
|
||||
<span className="text-xs text-muted">Paused</span>
|
||||
<button
|
||||
onClick={jumpToBottom}
|
||||
className="text-xs font-medium text-blue hover:underline"
|
||||
>
|
||||
Jump to bottom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/server/ServerRow.tsx
Normal file
86
src/components/server/ServerRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/statistics/DailyActivityChart.tsx
Executable file
77
src/components/statistics/DailyActivityChart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
95
src/components/statistics/PlayerCountChart.tsx
Executable file
95
src/components/statistics/PlayerCountChart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
86
src/components/statistics/RecentSessions.tsx
Executable file
86
src/components/statistics/RecentSessions.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
65
src/components/statistics/SessionTypesChart.tsx
Executable file
65
src/components/statistics/SessionTypesChart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
17
src/components/statistics/StatCard.tsx
Executable file
17
src/components/statistics/StatCard.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
64
src/components/statistics/StatisticsDashboard.tsx
Executable file
64
src/components/statistics/StatisticsDashboard.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/AppShell.tsx
Normal file
19
src/components/ui/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/Badge.tsx
Normal file
31
src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/Button.tsx
Normal file
49
src/components/ui/Button.tsx
Normal 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
29
src/components/ui/Input.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
|
||||
<input
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
24
src/components/ui/LoadingSpinner.tsx
Executable file
24
src/components/ui/LoadingSpinner.tsx
Executable 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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/LogoutButton.tsx
Executable file
29
src/components/ui/LogoutButton.tsx
Executable 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
57
src/components/ui/Modal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
73
src/components/ui/Pagination.tsx
Executable file
73
src/components/ui/Pagination.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
13
src/components/ui/RefreshButton.tsx
Executable file
13
src/components/ui/RefreshButton.tsx
Executable 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
36
src/components/ui/Select.tsx
Executable 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';
|
||||
143
src/components/ui/Sidebar.tsx
Normal file
143
src/components/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import LogoutButton from './LogoutButton';
|
||||
|
||||
interface SidebarProps {
|
||||
showUsers: boolean; // true if user has membership.view permission
|
||||
}
|
||||
|
||||
function ServerIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="5" rx="1" />
|
||||
<rect x="2" y="10" width="20" height="5" rx="1" />
|
||||
<rect x="2" y="17" width="20" height="5" rx="1" />
|
||||
<circle cx="6" cy="5.5" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="12.5" r="1" fill="currentColor" />
|
||||
<circle cx="6" cy="19.5" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLogIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className={cn('h-5 w-5', active ? 'text-blue' : 'text-subtle')}
|
||||
>
|
||||
<path d="M9 12h6M9 16h6M9 8h6M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ showUsers }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const isServers = pathname === '/dashboard' || pathname.startsWith('/dashboard/server');
|
||||
const isUsers = pathname.startsWith('/dashboard/membership');
|
||||
const isActivityLog = pathname.startsWith('/dashboard/activity-log');
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
label: 'Servers',
|
||||
active: isServers,
|
||||
icon: <ServerIcon active={isServers} />
|
||||
},
|
||||
{
|
||||
href: '/dashboard/activity-log',
|
||||
label: 'Activity Log',
|
||||
active: isActivityLog,
|
||||
icon: <ActivityLogIcon active={isActivityLog} />
|
||||
},
|
||||
...(showUsers
|
||||
? [
|
||||
{
|
||||
href: '/dashboard/membership',
|
||||
label: 'Users',
|
||||
active: isUsers,
|
||||
icon: <UsersIcon active={isUsers} />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="border-border-muted bg-sidebar fixed inset-y-0 left-0 z-40 hidden w-14 flex-col items-center border-r py-3 md:flex">
|
||||
<div className="bg-blue mb-4 flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#0d1117"
|
||||
strokeWidth={2.5}
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
|
||||
item.active ? 'bg-blue-bg' : 'hover:bg-overlay'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<LogoutButton />
|
||||
</aside>
|
||||
|
||||
<nav className="border-border-muted bg-sidebar fixed inset-x-0 bottom-0 z-40 flex h-14 items-center justify-around border-t md:hidden">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 px-4 py-2 text-xs transition-colors',
|
||||
item.active ? 'text-blue' : 'text-subtle'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/components/ui/StatusDot.tsx
Normal file
28
src/components/ui/StatusDot.tsx
Normal 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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/SteamCMDNotification.tsx
Executable file
46
src/components/ui/SteamCMDNotification.tsx
Executable 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
49
src/components/ui/Toast.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
25
src/components/websocket/WebSocketInitializer.tsx
Executable file
25
src/components/websocket/WebSocketInitializer.tsx
Executable 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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
20
src/lib/actions/activityLog.ts
Normal file
20
src/lib/actions/activityLog.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { getActivityLog } from '@/lib/api/server/activityLog';
|
||||
import type { ActivityLog, ActivityLogFilter } from '@/lib/schemas/activityLog';
|
||||
|
||||
export async function getActivityLogAction(
|
||||
filter?: ActivityLogFilter
|
||||
): Promise<{ success: true; data: ActivityLog[] } | { success: false; message: string }> {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = await getActivityLog(session.token!, filter);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch activity log'
|
||||
};
|
||||
}
|
||||
}
|
||||
53
src/lib/actions/auth.ts
Executable file
53
src/lib/actions/auth.ts
Executable 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
261
src/lib/actions/configuration.ts
Executable 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
24
src/lib/actions/leaderboard.ts
Executable 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
48
src/lib/actions/membership.ts
Executable 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user