Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
52
.gitignore
vendored
@@ -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
|
||||
|
||||
10
.prettierrc
10
.prettierrc
@@ -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
40
Dockerfile
@@ -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
52
README.md
@@ -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
Normal file
25
eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone'
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6334
package-lock.json
generated
6334
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"name": "acc-server-manager-web",
|
||||
"version": "0.20.2",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@ethercorps/sveltekit-redis-session": "^1.3.1",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.14.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"uuid": "^11.0.5",
|
||||
"vite": "^6.0.0"
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@date-fns/utc": "^2.1.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0"
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"ioredis": "^5.7.0",
|
||||
"iron-session": "^8.0.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"next": "15.5.2",
|
||||
"react": "19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
};
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal 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>
|
||||
23
src/app/dashboard/layout.tsx
Normal file
23
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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';
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await requireAuth();
|
||||
return (
|
||||
<WebSocketProvider openToken={session.openToken!}>
|
||||
<SteamCMDProvider>
|
||||
<ServerCreationPopupProvider>
|
||||
{children}
|
||||
<ServerCreationPopupContainer />
|
||||
</ServerCreationPopupProvider>
|
||||
</SteamCMDProvider>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
38
src/app/dashboard/membership/page.tsx
Normal file
38
src/app/dashboard/membership/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { getUsers, getRoles } from '@/lib/api/server/membership';
|
||||
import type { UserListParams } from '@/lib/api/server/membership';
|
||||
import { UserManagementTable } from '@/components/membership/UserManagementTable';
|
||||
|
||||
interface MembershipPageProps {
|
||||
searchParams: {
|
||||
username?: string;
|
||||
role_name?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: string;
|
||||
page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MembershipPage({ searchParams }: MembershipPageProps) {
|
||||
const session = await requireAuth();
|
||||
|
||||
const params: UserListParams = {
|
||||
username: searchParams.username,
|
||||
role_name: searchParams.role_name,
|
||||
sort_by: searchParams.sort_by || 'username',
|
||||
sort_desc: searchParams.sort_desc === 'true',
|
||||
page: parseInt(searchParams.page || '1'),
|
||||
limit: 20
|
||||
};
|
||||
|
||||
const [userListData, roles] = await Promise.all([
|
||||
getUsers(session.token!, params),
|
||||
getRoles(session.token!)
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<UserManagementTable initialData={userListData} roles={roles} currentUser={session.user!} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/app/dashboard/page.tsx
Normal file
53
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { getServers } from '@/lib/api/server/servers';
|
||||
import { hasPermission } from '@/lib/schemas';
|
||||
import Link from 'next/link';
|
||||
import { ServerListWithActions } from '@/components/server/ServerListWithActions';
|
||||
import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification';
|
||||
import LogoutButton from '@/components/ui/LogoutButton';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireAuth();
|
||||
const servers = await getServers(session.token!);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-bold">ACC Server Manager</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
{hasPermission(session.user!, 'membership.view') && (
|
||||
<Link
|
||||
href="/dashboard/membership"
|
||||
className="flex items-center text-gray-300 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M15 21a6 6 0 00-9-5.197m0 0A5.975 5.975 0 0112 13a5.975 5.975 0 01-3-1.197"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-1 hidden sm:inline">Users</span>
|
||||
</Link>
|
||||
)}
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SteamCMDNotification />
|
||||
|
||||
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<ServerListWithActions servers={servers} user={session.user!} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/dashboard/server/[id]/page.tsx
Normal file
43
src/app/dashboard/server/[id]/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { getServer } from '@/lib/api/server/servers';
|
||||
import { getServerConfigurations } from '@/lib/api/server/configuration';
|
||||
import { ServerConfigurationTabs } from '@/components/server/ServerConfigurationTabs';
|
||||
import { ServerHeader } from '@/components/server/ServerHeader';
|
||||
import { getServerStatistics } from '@/lib/api/server/statistics';
|
||||
import { subDays, formatISO } from 'date-fns';
|
||||
import { UTCDate } from '@date-fns/utc';
|
||||
|
||||
interface ServerPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function ServerPage({ params }: ServerPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await requireAuth();
|
||||
|
||||
const today = new UTCDate();
|
||||
const endDate = formatISO(today);
|
||||
const startDate = formatISO(subDays(today, 30));
|
||||
|
||||
const [server, configurations, statistics] = await Promise.all([
|
||||
getServer(session.token!, id),
|
||||
getServerConfigurations(session.token!, id),
|
||||
getServerStatistics(session.token!, id, { startDate, endDate })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<ServerHeader server={server} user={session.user!} />
|
||||
|
||||
<div className="mt-8">
|
||||
<ServerConfigurationTabs
|
||||
serverId={id}
|
||||
configurations={configurations}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
11
src/app/globals.css
Normal file
11
src/app/globals.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer components {
|
||||
.form-input {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
}
|
||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal 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-gray-900 text-white antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
15
src/app/login/page.tsx
Normal file
15
src/app/login/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Suspense } from 'react';
|
||||
import LoginForm from '@/components/login/LoginForm';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function LoginPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ expired: boolean | undefined }>;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LoginForm searchParams={searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
12
src/app/page.tsx
Normal file
12
src/app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSession } from '@/lib/auth/server';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (session.token && session.user) {
|
||||
redirect('/dashboard');
|
||||
} else {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type AssistRules } from '$models/config';
|
||||
|
||||
const { config, id }: { config: AssistRules; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.assistRules} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Stability Control Level Max -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Stability Control Level Max:
|
||||
<input
|
||||
bind:value={editedConfig.stabilityControlLevelMax}
|
||||
disabled={formLoading}
|
||||
name="stabilityControlLevelMax"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Autosteer -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Autosteer:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutosteer}
|
||||
disabled={formLoading}
|
||||
name="disableAutosteer"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Lights -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Lights:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoLights}
|
||||
disabled={formLoading}
|
||||
name="disableAutoLights"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Wiper -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Wiper:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoWiper}
|
||||
disabled={formLoading}
|
||||
name="disableAutoWiper"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Engine Start -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Engine Start:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoEngineStart}
|
||||
disabled={formLoading}
|
||||
name="disableAutoEngineStart"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Pit Limiter -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Pit Limiter:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoPitLimiter}
|
||||
disabled={formLoading}
|
||||
name="disableAutoPitLimiter"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Gear -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Gear:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoGear}
|
||||
disabled={formLoading}
|
||||
name="disableAutoGear"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Auto Clutch -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Auto Clutch:
|
||||
<select
|
||||
bind:value={editedConfig.disableAutoClutch}
|
||||
disabled={formLoading}
|
||||
name="disableAutoClutch"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Disable Ideal Line -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Disable Ideal Line:
|
||||
<select
|
||||
bind:value={editedConfig.disableIdealLine}
|
||||
disabled={formLoading}
|
||||
name="disableIdealLine"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={restart}
|
||||
name="restart"
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type Configuration } from '$models/config';
|
||||
|
||||
const { config, id }: { config: Configuration; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.configuration} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
UDP Port:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="udpPort"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.udpPort}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
TCP Port:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="tcpPort"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.tcpPort}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Connections:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="maxConnections"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.maxConnections}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Lan Discovery:
|
||||
<select
|
||||
bind:value={editedConfig.lanDiscovery}
|
||||
disabled={formLoading}
|
||||
name="lanDiscovery"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Register To Lobby:
|
||||
<select
|
||||
bind:value={editedConfig.registerToLobby}
|
||||
disabled={formLoading}
|
||||
name="registerToLobby"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input type="hidden" name="configVersion" value={1} />
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,328 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type EventConfig } from '$models/config';
|
||||
import type { Track } from '$models/lookups';
|
||||
|
||||
const { config, tracks, id }: { config: EventConfig; tracks: Track[]; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
if (!editedConfig.sessions) editedConfig.sessions = [];
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.event} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Track:
|
||||
<select
|
||||
bind:value={editedConfig.track}
|
||||
disabled={formLoading}
|
||||
name="track"
|
||||
class="form form-select"
|
||||
>
|
||||
{#each tracks as track}
|
||||
<option value={track.track}>{track.track}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Pre-Race waiting time seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="preRaceWaitingTimeSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.preRaceWaitingTimeSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session over time seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="sessionOverTimeSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.sessionOverTimeSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Ambient temp:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="ambientTemp"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.ambientTemp}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Cloud level:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="cloudLevel"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.cloudLevel}
|
||||
step=".01"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Rain:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="rain"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.rain}
|
||||
step=".01"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Weather randomness:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="weatherRandomness"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.weatherRandomness}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Post-Qualy seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="postQualySeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.postQualySeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Post-Race seconds:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="postRaceSeconds"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.postRaceSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Simracer weather conditions:
|
||||
<input
|
||||
disabled={formLoading}
|
||||
name="simracerWeatherConditions"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
bind:value={editedConfig.simracerWeatherConditions}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is fixed condition qualification:
|
||||
<select
|
||||
bind:value={editedConfig.isFixedConditionQualification}
|
||||
disabled={formLoading}
|
||||
name="isFixedConditionQualification"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Sessions:
|
||||
<div class="mt-2 space-y-4">
|
||||
{#each editedConfig.sessions as session, index}
|
||||
<div class="mb-4 rounded-lg border border-gray-700 bg-gray-800 p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Hour of Day -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300"
|
||||
>Hour of Day:
|
||||
<input
|
||||
bind:value={session.hourOfDay}
|
||||
name={`sessions[${index}].hourOfDay`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Day of Weekend -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Day of Weekend:
|
||||
<input
|
||||
bind:value={session.dayOfWeekend}
|
||||
name={`sessions[${index}].dayOfWeekend`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Time Multiplier -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Time Multiplier:
|
||||
<input
|
||||
bind:value={session.timeMultiplier}
|
||||
name={`sessions[${index}].timeMultiplier`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Session Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session Type:
|
||||
<select
|
||||
bind:value={session.sessionType}
|
||||
name={`sessions[${index}].sessionType`}
|
||||
disabled={formLoading}
|
||||
class="form form-select"
|
||||
>
|
||||
<option value="P">Practice</option>
|
||||
<option value="Q">Qualifying</option>
|
||||
<option value="R">Race</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Session Duration Minutes -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Session Duration (Minutes):
|
||||
<input
|
||||
bind:value={session.sessionDurationMinutes}
|
||||
name={`sessions[${index}].sessionDurationMinutes`}
|
||||
disabled={formLoading}
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editedConfig.sessions = editedConfig.sessions.filter((_, i) => i !== index);
|
||||
}}
|
||||
class="mt-4 flex items-center rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Remove Session
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editedConfig.sessions = [
|
||||
...editedConfig.sessions,
|
||||
{
|
||||
hourOfDay: 14,
|
||||
dayOfWeekend: 1,
|
||||
timeMultiplier: 1,
|
||||
sessionType: 'Practice',
|
||||
sessionDurationMinutes: 60
|
||||
}
|
||||
];
|
||||
}}
|
||||
class="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Session
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,213 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type EventRules } from '$models/config';
|
||||
|
||||
const { config, id }: { config: EventRules; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.eventRules} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Qualify Standing Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Qualify Standing Type:
|
||||
<input
|
||||
bind:value={editedConfig.qualifyStandingType}
|
||||
disabled={formLoading}
|
||||
name="qualifyStandingType"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Pit Window Length Sec -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Pit Window Length (Sec):
|
||||
<input
|
||||
bind:value={editedConfig.pitWindowLengthSec}
|
||||
disabled={formLoading}
|
||||
name="pitWindowLengthSec"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Driver Stint Time Sec -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Driver Stint Time (Sec):
|
||||
<input
|
||||
bind:value={editedConfig.driverStintTimeSec}
|
||||
disabled={formLoading}
|
||||
name="driverStintTimeSec"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mandatory Pitstop Count -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Mandatory Pitstop Count:
|
||||
<input
|
||||
bind:value={editedConfig.mandatoryPitstopCount}
|
||||
disabled={formLoading}
|
||||
name="mandatoryPitstopCount"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max Total Driving Time -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Total Driving Time:
|
||||
<input
|
||||
bind:value={editedConfig.maxTotalDrivingTime}
|
||||
disabled={formLoading}
|
||||
name="maxTotalDrivingTime"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Refuelling Allowed In Race -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Refuelling Allowed In Race:
|
||||
<select
|
||||
bind:value={editedConfig.isRefuellingAllowedInRace}
|
||||
disabled={formLoading}
|
||||
name="isRefuellingAllowedInRace"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Refuelling Time Fixed -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Refuelling Time Fixed:
|
||||
<select
|
||||
bind:value={editedConfig.isRefuellingTimeFixed}
|
||||
disabled={formLoading}
|
||||
name="isRefuellingTimeFixed"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Refuelling Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Refuelling Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopRefuellingRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopRefuellingRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Tyre Change Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Tyre Change Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopTyreChangeRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopTyreChangeRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Mandatory Pitstop Swap Driver Required -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Mandatory Pitstop Swap Driver Required:
|
||||
<select
|
||||
bind:value={editedConfig.isMandatoryPitstopSwapDriverRequired}
|
||||
disabled={formLoading}
|
||||
name="isMandatoryPitstopSwapDriverRequired"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tyre Set Count -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Tyre Set Count:
|
||||
<input
|
||||
bind:value={editedConfig.tyreSetCount}
|
||||
disabled={formLoading}
|
||||
name="tyreSetCount"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="restart"
|
||||
bind:checked={restart}
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,306 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { configFile, type ServerSettings } from '$models/config';
|
||||
|
||||
const { config, id }: { config: ServerSettings; id: string } = $props();
|
||||
const editedConfig = $state({ ...config });
|
||||
let formLoading = $state(false);
|
||||
let restart = $state(true);
|
||||
const carGroups = ['FreeForAll', 'GT3', 'GT4', 'GT2', 'GTC', 'TCX'];
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
formLoading = true;
|
||||
return async ({ update }) => {
|
||||
await update({ invalidateAll: true, reset: false });
|
||||
formLoading = false;
|
||||
};
|
||||
}}
|
||||
class="max-w-3xl space-y-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<input type="hidden" name="file" value={configFile.settings} />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Server Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Server Name:
|
||||
<input
|
||||
bind:value={editedConfig.serverName}
|
||||
disabled={formLoading}
|
||||
name="serverName"
|
||||
type="text"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Admin Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Admin Password:
|
||||
<input
|
||||
bind:value={editedConfig.adminPassword}
|
||||
disabled={formLoading}
|
||||
name="adminPassword"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Car Group -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Car Group:
|
||||
<select
|
||||
bind:value={editedConfig.carGroup}
|
||||
disabled={formLoading}
|
||||
name="carGroup"
|
||||
class="form form-select"
|
||||
>
|
||||
{#each carGroups as group}
|
||||
<option value={group}>{group}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Track Medals Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Track Medals Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.trackMedalsRequirement}
|
||||
disabled={formLoading}
|
||||
name="trackMedalsRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Safety Rating Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Safety Rating Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.safetyRatingRequirement}
|
||||
disabled={formLoading}
|
||||
name="safetyRatingRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Racecraft Rating Requirement -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Racecraft Rating Requirement:
|
||||
<input
|
||||
bind:value={editedConfig.racecraftRatingRequirement}
|
||||
disabled={formLoading}
|
||||
name="racecraftRatingRequirement"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Password:
|
||||
<input
|
||||
bind:value={editedConfig.password}
|
||||
disabled={formLoading}
|
||||
name="password"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Spectator Password -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Spectator Password:
|
||||
<input
|
||||
bind:value={editedConfig.spectatorPassword}
|
||||
disabled={formLoading}
|
||||
name="spectatorPassword"
|
||||
type="password"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Max Car Slots -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Max Car Slots:
|
||||
<input
|
||||
bind:value={editedConfig.maxCarSlots}
|
||||
disabled={formLoading}
|
||||
name="maxCarSlots"
|
||||
type="number"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Dump Leaderboards -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Dump Leaderboards:
|
||||
<select
|
||||
bind:value={editedConfig.dumpLeaderboards}
|
||||
disabled={formLoading}
|
||||
name="dumpLeaderboards"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Race Locked -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Is Race Locked:
|
||||
<select
|
||||
bind:value={editedConfig.isRaceLocked}
|
||||
disabled={formLoading}
|
||||
name="isRaceLocked"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Randomize Track When Empty -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Randomize Track When Empty:
|
||||
<select
|
||||
bind:value={editedConfig.randomizeTrackWhenEmpty}
|
||||
disabled={formLoading}
|
||||
name="randomizeTrackWhenEmpty"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Central Entry List Path -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Central Entry List Path:
|
||||
<input
|
||||
bind:value={editedConfig.centralEntryListPath}
|
||||
disabled={formLoading}
|
||||
name="centralEntryListPath"
|
||||
type="text"
|
||||
class="form form-input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Allow Auto DQ -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Allow Auto DQ:
|
||||
<select
|
||||
bind:value={editedConfig.allowAutoDQ}
|
||||
disabled={formLoading}
|
||||
name="allowAutoDQ"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Short Formation Lap -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Short Formation Lap:
|
||||
<select
|
||||
bind:value={editedConfig.shortFormationLap}
|
||||
disabled={formLoading}
|
||||
name="shortFormationLap"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Formation Lap Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Formation Lap Type:
|
||||
<select
|
||||
bind:value={editedConfig.formationLapType}
|
||||
disabled={formLoading}
|
||||
name="formationLapType"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>Old Limiter Lap</option>
|
||||
<option value={1}>Free (replaces /manual start), only usable for private servers</option>
|
||||
<option value={3}>Default formation lap with position control and UI</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ignore Premature Disconnects -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Ignore Premature Disconnects:
|
||||
<select
|
||||
bind:value={editedConfig.ignorePrematureDisconnects}
|
||||
disabled={formLoading}
|
||||
name="ignorePrematureDisconnects"
|
||||
class="form form-select"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={restart}
|
||||
name="restart"
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style></style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let currentPage: number = 1;
|
||||
export let totalPages: number = 1;
|
||||
export let onPageChange: (page: number) => void = () => {};
|
||||
|
||||
$: pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
function generatePageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (current <= 4) {
|
||||
// Show first 5 pages, then ellipsis, then last page
|
||||
pages.push(...[1, 2, 3, 4, 5]);
|
||||
if (total > 6) pages.push('...');
|
||||
pages.push(total);
|
||||
} else if (current >= total - 3) {
|
||||
// Show first page, ellipsis, then last 5 pages
|
||||
pages.push(1);
|
||||
if (total > 6) pages.push('...');
|
||||
pages.push(...[total - 4, total - 3, total - 2, total - 1, total]);
|
||||
} else {
|
||||
// Show first page, ellipsis, current and neighbors, ellipsis, last page
|
||||
pages.push(1, '...', current - 1, current, current + 1, '...', total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function handlePageClick(page: number | string) {
|
||||
if (typeof page === 'number' && page !== currentPage) {
|
||||
onPageChange(page);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (currentPage < totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center space-x-1">
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === 1
|
||||
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
disabled={currentPage === 1}
|
||||
onclick={handlePrevious}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<!-- Page numbers -->
|
||||
{#each pages as page}
|
||||
{#if page === '...'}
|
||||
<span class="px-3 py-2 text-sm text-gray-500">...</span>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
onclick={() => handlePageClick(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Next button -->
|
||||
<button
|
||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === totalPages
|
||||
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={handleNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page info -->
|
||||
<div class="mt-2 text-center text-sm text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Server } from '$models/server';
|
||||
import { getStatusColor, ServiceStatus, serviceStatusToString } from '$lib/types/serviceStatus';
|
||||
|
||||
let { server }: { server: Server } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
|
||||
<a href={`dashboard/server/${server.id}`}>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">{server.name}</h3>
|
||||
<div class="mt-1 flex items-center">
|
||||
<span class={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
|
||||
></span>
|
||||
<span class="text-sm capitalize">{serviceStatusToString(server.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
|
||||
<div>
|
||||
<span class="text-gray-500">Track:</span>
|
||||
{server.state?.track}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Players:</span>
|
||||
{server.state?.playerCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<form method="POST" action="?/start">
|
||||
<div class="flex justify-between bg-gray-900 px-4 py-3">
|
||||
<input type="hidden" name="id" value={server.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={server.status === ServiceStatus.Running}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-green-600 px-3 py-1 text-xs font-medium hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
disabled={server.status === ServiceStatus.Stopped}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
formaction="?/restart"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
disabled={server.status === ServiceStatus.Stopped}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="rounded-md bg-red-600 px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
formaction="?/stop"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,346 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Chart from 'chart.js/auto';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { compareAsc, isSameDay } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import type { StateHistoryStats } from '$models/config';
|
||||
import { flatMap } from 'lodash-es';
|
||||
|
||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
interface Props {
|
||||
// Props
|
||||
statistics: StateHistoryStats;
|
||||
}
|
||||
|
||||
let { statistics }: Props = $props();
|
||||
|
||||
// Chart instances
|
||||
let playerCountChart: Chart | null = null;
|
||||
let sessionTypeChart: Chart | null = null;
|
||||
let dailyActivityChart: Chart | null = null;
|
||||
|
||||
// Chart canvas elements
|
||||
let playerCountCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let sessionTypeCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let dailyActivityCanvas: HTMLCanvasElement | undefined = $state();
|
||||
|
||||
// Initialize date range (last 30 days by default)
|
||||
onMount(() => {
|
||||
createCharts();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Cleanup charts
|
||||
if (playerCountChart) playerCountChart.destroy();
|
||||
if (sessionTypeChart) sessionTypeChart.destroy();
|
||||
if (dailyActivityChart) dailyActivityChart.destroy();
|
||||
});
|
||||
|
||||
function createCharts() {
|
||||
if (!statistics || !playerCountCanvas || !sessionTypeCanvas || !dailyActivityCanvas) return;
|
||||
|
||||
playerCountChart = new Chart(playerCountCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: statistics.playerCountOverTime.map(({ timestamp }) =>
|
||||
formatDate(timestamp, 'MMM dd kk:mm')
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Player Count',
|
||||
data: statistics.playerCountOverTime.map(({ count }) => count),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sessionTypeChart = new Chart(sessionTypeCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: statistics.sessionTypes.map(({ name }) => name),
|
||||
datasets: [
|
||||
{
|
||||
data: statistics.sessionTypes.map(({ count }) => count),
|
||||
backgroundColor: ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dailyActivityChart = new Chart(dailyActivityCanvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: statistics.dailyActivity.map(({ date }) => date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Sessions',
|
||||
data: statistics.dailyActivity.map(({ sessionsCount }) => sessionsCount),
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(dateString: string, formatString: string) {
|
||||
return formatInTimeZone(dateString, localTimeZone, formatString, {
|
||||
timeZone: 'utc'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-green-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Average Players</p>
|
||||
<p class="text-2xl font-bold text-white">{Math.round(statistics.averagePlayers)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-yellow-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Peak Players</p>
|
||||
<p class="text-2xl font-bold text-white">{statistics.peakPlayers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-blue-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Sessions</p>
|
||||
<p class="text-2xl font-bold text-white">{statistics.totalSessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-lg bg-purple-600 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-400">Total Playtime</p>
|
||||
<p class="text-2xl font-bold text-white">{formatDuration(statistics.totalPlaytime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Player Count Over Time -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Player Count Over Time</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={playerCountCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Types Pie Chart -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Session Types</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={sessionTypeCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Activity Bar Chart -->
|
||||
<div class="mb-8 rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Daily Activity</h3>
|
||||
<div class="h-64">
|
||||
<canvas bind:this={dailyActivityCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session History Table -->
|
||||
<div class="rounded-lg bg-gray-800 p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold">Recent Sessions</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Date</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Type</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Track</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Duration</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-300 uppercase"
|
||||
>Players</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||
{#each statistics.recentSessions as session}
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDate(session.date, 'MMM dd kk:mm')}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
||||
session.type === 'Race'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: session.type === 'Qualifying'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}
|
||||
>
|
||||
{session.type}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.track}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDuration(session.duration)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.players}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { message = '', type = 'success', duration = 5000 } = $props();
|
||||
|
||||
let visible = $state(true);
|
||||
|
||||
let timer: number;
|
||||
|
||||
onMount(() => {
|
||||
timer = window.setTimeout(() => {
|
||||
visible = false;
|
||||
}, duration);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
const baseClasses =
|
||||
'fixed bottom-5 right-5 p-4 rounded-lg shadow-lg text-white transition-all duration-500 ease-in-out transform';
|
||||
const typeClasses = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600'
|
||||
};
|
||||
const visibilityClasses = visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-5';
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="{baseClasses} {typeClasses[type]} {visibilityClasses}" role="alert">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
148
src/components/configuration/AssistRulesEditor.tsx
Normal file
148
src/components/configuration/AssistRulesEditor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { AssistRules } from '@/lib/schemas/config';
|
||||
import { updateAssistRulesAction } from '@/lib/actions/configuration';
|
||||
|
||||
interface AssistRulesEditorProps {
|
||||
serverId: string;
|
||||
config: AssistRules;
|
||||
}
|
||||
|
||||
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({ serverId, config }: AssistRulesEditorProps) {
|
||||
const [formData, setFormData] = useState<AssistRules>(config);
|
||||
const [restart, setRestart] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
formDataObj.append(key, value.toString());
|
||||
});
|
||||
if (restart) {
|
||||
formDataObj.append('restart', 'on');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateAssistRulesAction(serverId, formDataObj);
|
||||
if (!result.success) {
|
||||
console.error('Failed to update assist rules:', result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof AssistRules, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: typeof value === 'string' ? parseInt(value) : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{assistFields.map(({ key, label, type }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
{type === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData[key]}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData[key]}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>Allowed</option>
|
||||
<option value={1}>Disabled</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={restart}
|
||||
onChange={(e) => setRestart(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
132
src/components/configuration/ConfigurationEditor.tsx
Normal file
132
src/components/configuration/ConfigurationEditor.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Configuration } from '@/lib/schemas/config';
|
||||
import { updateConfigurationAction } from '@/lib/actions/configuration';
|
||||
|
||||
interface ConfigurationEditorProps {
|
||||
serverId: string;
|
||||
config: Configuration;
|
||||
}
|
||||
|
||||
export function ConfigurationEditor({ serverId, config }: ConfigurationEditorProps) {
|
||||
const [formData, setFormData] = useState<Configuration>(config);
|
||||
const [restart, setRestart] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
formDataObj.append(key, value.toString());
|
||||
});
|
||||
if (restart) {
|
||||
formDataObj.append('restart', 'on');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateConfigurationAction(serverId, formDataObj);
|
||||
if (!result.success) {
|
||||
console.error('Failed to update configuration:', result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof Configuration, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: typeof value === 'string' ? parseInt(value) : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">UDP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.udpPort}
|
||||
onChange={(e) => handleInputChange('udpPort', e.target.value)}
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">TCP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.tcpPort}
|
||||
onChange={(e) => handleInputChange('tcpPort', e.target.value)}
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Max Connections</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.maxConnections}
|
||||
onChange={(e) => handleInputChange('maxConnections', e.target.value)}
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">LAN Discovery</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.lanDiscovery}
|
||||
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Register To Lobby</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.registerToLobby}
|
||||
onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={restart}
|
||||
onChange={(e) => setRestart(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
432
src/components/configuration/EventConfigEditor.tsx
Normal file
432
src/components/configuration/EventConfigEditor.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { EventConfig, Session } from '@/lib/schemas/config';
|
||||
import { updateEventConfigAction } from '@/lib/actions/configuration';
|
||||
|
||||
interface EventConfigEditorProps {
|
||||
serverId: string;
|
||||
config: EventConfig;
|
||||
}
|
||||
|
||||
const sessionTypes = [
|
||||
{ value: 'P', label: 'Practice' },
|
||||
{ value: 'Q', label: 'Qualifying' },
|
||||
{ value: 'R', label: 'Race' }
|
||||
];
|
||||
|
||||
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) {
|
||||
const [formData, setFormData] = useState<EventConfig>(config);
|
||||
const [restart, setRestart] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (key === 'sessions') {
|
||||
formDataObj.append(key, JSON.stringify(value));
|
||||
} else {
|
||||
formDataObj.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
if (restart) {
|
||||
formDataObj.append('restart', 'on');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateEventConfigAction(serverId, formDataObj);
|
||||
if (!result.success) {
|
||||
console.error('Failed to update event config:', result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof EventConfig, value: string | number) => {
|
||||
if (key === 'sessions') return;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]:
|
||||
typeof formData[key] === 'number'
|
||||
? typeof value === 'string'
|
||||
? parseFloat(value) || 0
|
||||
: value
|
||||
: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSessionChange = (index: number, field: keyof Session, value: string | number) => {
|
||||
const newSessions = [...formData.sessions];
|
||||
newSessions[index] = {
|
||||
...newSessions[index],
|
||||
[field]:
|
||||
field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sessions: newSessions
|
||||
}));
|
||||
};
|
||||
|
||||
const addSession = () => {
|
||||
const newSession: Session = {
|
||||
hourOfDay: 12,
|
||||
dayOfWeekend: 1,
|
||||
timeMultiplier: 1,
|
||||
sessionType: 'P',
|
||||
sessionDurationMinutes: 20
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sessions: [...prev.sessions, newSession]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeSession = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sessions: prev.sessions.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Basic Event Settings
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Track</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.track}
|
||||
onChange={(e) => handleInputChange('track', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value="barcelona">Barcelona</option>
|
||||
<option value="brands_hatch">Brands Hatch</option>
|
||||
<option value="donington">Donington</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-2 block text-sm font-medium text-gray-300">
|
||||
Pre-Race Waiting Time (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.preRaceWaitingTimeSeconds}
|
||||
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Session Over Time (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.sessionOverTimeSeconds}
|
||||
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Post Qualify Seconds
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.postQualySeconds}
|
||||
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Post Race Seconds
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.postRaceSeconds}
|
||||
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Weather Settings
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Ambient Temperature (°C)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.ambientTemp}
|
||||
onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
max="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Cloud Level (0.0-1.0)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.cloudLevel}
|
||||
onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Rain (0.0-1.0)</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.rain}
|
||||
onChange={(e) => handleInputChange('rain', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Weather Randomness
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData.weatherRandomness}
|
||||
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
|
||||
className="form-input w-full"
|
||||
min="0"
|
||||
max="7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Simracer Weather Conditions
|
||||
</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.simracerWeatherConditions}
|
||||
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Fixed Condition Qualification
|
||||
</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.isFixedConditionQualification}
|
||||
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-gray-700 pb-2">
|
||||
<h3 className="text-lg font-medium text-white">Sessions</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSession}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-blue-600 px-3 py-1 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{formData.sessions.map((session, index) => (
|
||||
<div key={index} className="rounded-lg bg-gray-800 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-white">Session {index + 1}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSession(index)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Session Type
|
||||
</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={session.sessionType}
|
||||
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
|
||||
className="form-input w-full text-sm"
|
||||
>
|
||||
{sessionTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Hour of Day
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={session.hourOfDay}
|
||||
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
|
||||
className="form-input w-full text-sm"
|
||||
min="0"
|
||||
max="23"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Day of Weekend
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={session.dayOfWeekend}
|
||||
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
|
||||
className="form-input w-full text-sm"
|
||||
min="1"
|
||||
max="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Time Multiplier
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={session.timeMultiplier}
|
||||
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
|
||||
className="form-input w-full text-sm"
|
||||
min="1"
|
||||
max="24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={session.sessionDurationMinutes}
|
||||
onChange={(e) =>
|
||||
handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
|
||||
}
|
||||
className="form-input w-full text-sm"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={restart}
|
||||
onChange={(e) => setRestart(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
172
src/components/configuration/EventRulesEditor.tsx
Normal file
172
src/components/configuration/EventRulesEditor.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { EventRules } from '@/lib/schemas/config';
|
||||
import { updateEventRulesAction } from '@/lib/actions/configuration';
|
||||
|
||||
interface EventRulesEditorProps {
|
||||
serverId: string;
|
||||
config: EventRules;
|
||||
}
|
||||
|
||||
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({ serverId, config }: EventRulesEditorProps) {
|
||||
const [formData, setFormData] = useState<EventRules>(config);
|
||||
const [restart, setRestart] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
formDataObj.append(key, value.toString());
|
||||
});
|
||||
if (restart) {
|
||||
formDataObj.append('restart', 'on');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateEventRulesAction(serverId, formDataObj);
|
||||
if (!result.success) {
|
||||
console.error('Failed to update event rules:', result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof EventRules, value: string | number | boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">Race Rules</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{numberFields.map(({ key, label, min, max }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData[key] as number}
|
||||
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
|
||||
className="form-input w-full"
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Pitstop & Refuelling Rules
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{booleanFields.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData[key] ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange(key, e.target.value === 'true')}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={restart}
|
||||
onChange={(e) => setRestart(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
247
src/components/configuration/ServerSettingsEditor.tsx
Normal file
247
src/components/configuration/ServerSettingsEditor.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ServerSettings } from '@/lib/schemas/config';
|
||||
import { updateServerSettingsAction } from '@/lib/actions/configuration';
|
||||
|
||||
interface ServerSettingsEditorProps {
|
||||
serverId: string;
|
||||
config: ServerSettings;
|
||||
}
|
||||
|
||||
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({ serverId, config }: ServerSettingsEditorProps) {
|
||||
const [formData, setFormData] = useState<ServerSettings>(config);
|
||||
const [restart, setRestart] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
formDataObj.append(key, value.toString());
|
||||
});
|
||||
if (restart) {
|
||||
formDataObj.append('restart', 'on');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateServerSettingsAction(serverId, formDataObj);
|
||||
if (!result.success) {
|
||||
console.error('Failed to update server settings:', result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Basic Settings
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{textFields.map(({ key, label, type }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
disabled={isSubmitting}
|
||||
value={formData[key] as string}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Car Group</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.carGroup}
|
||||
onChange={(e) => handleInputChange('carGroup', e.target.value)}
|
||||
className="form-input w-full"
|
||||
>
|
||||
{carGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{group}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Requirements & Limits
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{numberFields.map(({ key, label, min, max }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
disabled={isSubmitting}
|
||||
value={formData[key] as number}
|
||||
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
|
||||
className="form-input w-full"
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="border-b border-gray-700 pb-2 text-lg font-medium text-white">
|
||||
Race Options
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{selectFields.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">{label}</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData[key] as number}
|
||||
onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>No</option>
|
||||
<option value={1}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Formation Lap Type
|
||||
</label>
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
value={formData.formationLapType}
|
||||
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
|
||||
className="form-input w-full"
|
||||
>
|
||||
<option value={0}>Old Limiter Lap</option>
|
||||
<option value={1}>
|
||||
Free (replaces /manual start), only usable for private servers
|
||||
</option>
|
||||
<option value={3}>Default formation lap with position control and UI</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={restart}
|
||||
onChange={(e) => setRestart(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-700 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-300">Restart server after saving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
82
src/components/login/LoginForm.tsx
Normal file
82
src/components/login/LoginForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth';
|
||||
import { use, useActionState, useEffect } from 'react';
|
||||
|
||||
const initialState: LoginResult = {
|
||||
message: '',
|
||||
success: true
|
||||
};
|
||||
|
||||
export default function LoginForm({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ expired: boolean | undefined }>;
|
||||
}) {
|
||||
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-gray-900 px-4">
|
||||
<div className="w-full max-w-md space-y-8 rounded-lg bg-gray-800 p-8 shadow-lg">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-white">ACC Server Manager</h1>
|
||||
<p className="mt-2 text-gray-400">Sign in to manage your servers</p>
|
||||
</div>
|
||||
{expired && (
|
||||
<div className="rounded-md border border-yellow-700 bg-yellow-900/50 p-3 text-sm text-yellow-200">
|
||||
Your session has expired. Please sign in again.
|
||||
</div>
|
||||
)}
|
||||
{state?.success ? null : (
|
||||
<div className="rounded-md border border-red-700 bg-red-900/50 p-3 text-sm text-red-200">
|
||||
{state?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-3 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-none"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/membership/CreateUserModal.tsx
Normal file
128
src/components/membership/CreateUserModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Role } from '@/lib/schemas';
|
||||
import { createUserAction } from '@/lib/actions/membership';
|
||||
|
||||
interface CreateUserModalProps {
|
||||
roles: Role[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateUserModal({ roles, onClose }: CreateUserModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
role: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('username', formData.username);
|
||||
formDataObj.append('password', formData.password);
|
||||
formDataObj.append('role', formData.role);
|
||||
|
||||
try {
|
||||
const result = await createUserAction(formDataObj);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">Create New User</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="create-username" className="block text-sm font-medium text-gray-300">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="create-username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="create-password" className="block text-sm font-medium text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="create-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="create-role" className="block text-sm font-medium text-gray-300">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="create-role"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, role: e.target.value }))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a role...</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.name}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/membership/DeleteUserModal.tsx
Normal file
75
src/components/membership/DeleteUserModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { User } from '@/lib/schemas';
|
||||
import { deleteUserAction } from '@/lib/actions/membership';
|
||||
|
||||
interface DeleteUserModalProps {
|
||||
user: User;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeleteUserModal({ user, onClose }: DeleteUserModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('id', user.id);
|
||||
|
||||
try {
|
||||
const result = await deleteUserAction(formDataObj);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||
<div className="w-full max-w-md rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">Delete User</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{error}</div>
|
||||
)}
|
||||
|
||||
<p className="mb-6 text-gray-300">
|
||||
Are you sure you want to delete the user "{user.username}"? This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Deleting...' : 'Delete User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
src/components/membership/UserManagementTable.tsx
Normal file
246
src/components/membership/UserManagementTable.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Role, User } from '@/lib/schemas';
|
||||
import { hasPermission } from '@/lib/schemas/user';
|
||||
import { CreateUserModal } from './CreateUserModal';
|
||||
import { DeleteUserModal } from './DeleteUserModal';
|
||||
|
||||
interface UserManagementTableProps {
|
||||
initialData: User[];
|
||||
roles: Role[];
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
export function UserManagementTable({ initialData, roles, currentUser }: UserManagementTableProps) {
|
||||
const router = useRouter();
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const [roleNameFilter, setRoleNameFilter] = useState('');
|
||||
const [sortBy, setSortBy] = useState('username');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const applyFilters = () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (usernameFilter) params.set('username', usernameFilter);
|
||||
if (roleNameFilter) params.set('role_name', roleNameFilter);
|
||||
params.set('sort_by', sortBy);
|
||||
params.set('sort_desc', sortDesc.toString());
|
||||
params.set('page', '1');
|
||||
|
||||
router.push(`/dashboard/membership?${params.toString()}`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setUsernameFilter('');
|
||||
setRoleNameFilter('');
|
||||
setSortBy('username');
|
||||
setSortDesc(false);
|
||||
router.push('/dashboard/membership');
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortBy === field) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortBy(field);
|
||||
setSortDesc(false);
|
||||
}
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortBy !== field) return '↕️';
|
||||
return sortDesc ? '↓' : '↑';
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
const openDeleteModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setSelectedUser(null);
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="mx-auto flex max-w-[120rem] items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/dashboard" className="text-gray-300 hover:text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">User Management</h1>
|
||||
</div>
|
||||
{hasPermission(currentUser, 'membership.create') && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Filters */}
|
||||
<div className="mb-6 rounded-lg border border-gray-700 bg-gray-800 p-4">
|
||||
<h2 className="mb-3 text-lg font-semibold">Filters</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-300">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
placeholder="Search by username..."
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-300">
|
||||
Role
|
||||
</label>
|
||||
<input
|
||||
id="role"
|
||||
type="text"
|
||||
value={roleNameFilter}
|
||||
onChange={(e) => setRoleNameFilter(e.target.value)}
|
||||
placeholder="Filter by role..."
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mb-4 text-sm text-gray-400">Showing {initialData.length} users</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<button
|
||||
className="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase transition-colors hover:text-white"
|
||||
onClick={() => handleSort('username')}
|
||||
>
|
||||
<span>Username</span>
|
||||
<span>{getSortIcon('username')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<button
|
||||
className="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase transition-colors hover:text-white"
|
||||
onClick={() => handleSort('role')}
|
||||
>
|
||||
<span>Role</span>
|
||||
<span>{getSortIcon('role')}</span>
|
||||
</button>
|
||||
</th>
|
||||
{hasPermission(currentUser, 'membership.edit') && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700 bg-gray-800">
|
||||
{initialData.length > 0 ? (
|
||||
initialData.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-gray-700">
|
||||
<td className="px-6 py-4 text-sm font-medium whitespace-nowrap text-white">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap">
|
||||
<span className="inline-flex rounded-full bg-blue-900 px-2 py-1 text-xs font-semibold text-blue-300">
|
||||
{user.role.name}
|
||||
</span>
|
||||
</td>
|
||||
{hasPermission(currentUser, 'membership.edit') && (
|
||||
<td className="px-6 py-4 text-sm font-medium whitespace-nowrap">
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-blue-400 transition-colors hover:text-blue-300">
|
||||
Edit
|
||||
</button>
|
||||
{hasPermission(currentUser, 'membership.delete') && (
|
||||
<button
|
||||
onClick={() => openDeleteModal(user)}
|
||||
className="text-red-400 transition-colors hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-6 py-4 text-center text-sm text-gray-400">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && <CreateUserModal roles={roles} onClose={closeCreateModal} />}
|
||||
|
||||
{showDeleteModal && selectedUser && (
|
||||
<DeleteUserModal user={selectedUser} onClose={closeDeleteModal} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/components/providers/QueryProvider.tsx
Normal file
13
src/components/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/api/client/query-client';
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
108
src/components/server/CreateServerModal.tsx
Normal file
108
src/components/server/CreateServerModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useActionState, useTransition } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management';
|
||||
import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext';
|
||||
|
||||
interface CreateServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const initialState: ServerActionResult = { success: false, message: '', data: undefined };
|
||||
|
||||
export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) {
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [submittedName, setSubmittedName] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPending, setTransition] = useTransition();
|
||||
|
||||
const [state, formAction] = useActionState(createServerAction, initialState);
|
||||
const { showPopup } = useServerCreationPopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success && state.data?.id) {
|
||||
showPopup(state.data.id, submittedName);
|
||||
onClose();
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [state.success, state.data, showPopup, onClose, submittedName]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) =>
|
||||
setTransition(async () => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!serverName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('name', serverName.trim());
|
||||
formAction(formData);
|
||||
setSubmittedName(serverName.trim());
|
||||
setServerName('');
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
setServerName('');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Create New Server">
|
||||
{!state.success && state.message && (
|
||||
<div className="mb-4 rounded-md bg-red-900 p-3 text-sm text-red-300">{state.message}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server-name" className="block text-sm font-medium text-gray-300">
|
||||
Server Name
|
||||
</label>
|
||||
<input
|
||||
id="server-name"
|
||||
type="text"
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting || isPending}
|
||||
className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
|
||||
placeholder="Enter server name..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting || isPending}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !serverName.trim() || isPending}
|
||||
className="flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner className="mr-2 h-4 w-4" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Server'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
79
src/components/server/DeleteServerModal.tsx
Normal file
79
src/components/server/DeleteServerModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { deleteServerAction } from '@/lib/actions/server-management';
|
||||
import { Server } from '@/lib/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 bg-red-900 p-3 text-sm text-red-300">{error}</div>}
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-300">
|
||||
Are you sure you want to delete the server <strong>"{server.name}"</strong>?
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400">This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="flex items-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Server'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
117
src/components/server/ServerCard.tsx
Normal file
117
src/components/server/ServerCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import Link from 'next/link';
|
||||
import { useTransition } from 'react';
|
||||
import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/schemas';
|
||||
import {
|
||||
startServerEventAction,
|
||||
restartServerEventAction,
|
||||
stopServerEventAction
|
||||
} from '@/lib/actions/servers';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-lg">
|
||||
<Link href={`/dashboard/server/${server.id}`} className="block">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{server.name}</h3>
|
||||
<div className="mt-2 flex items-center">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${getStatusColor(server.status)} mr-2`}
|
||||
/>
|
||||
<span className="text-sm text-gray-300 capitalize">
|
||||
{serviceStatusToString(server.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-gray-400 hover:text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<span className="text-gray-500">Track:</span>
|
||||
<span className="ml-2">{server.state?.track || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Players:</span>
|
||||
<span className="ml-2">{server.state?.playerCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between gap-2 bg-gray-900 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startServer}
|
||||
disabled={server.status === ServiceStatus.Running || isPending || disabled}
|
||||
className="rounded bg-green-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={restartServer}
|
||||
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
||||
className="rounded bg-yellow-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopServer}
|
||||
disabled={server.status === ServiceStatus.Stopped || isPending || disabled}
|
||||
className="rounded bg-red-600 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
src/components/server/ServerConfigurationTabs.tsx
Normal file
92
src/components/server/ServerConfigurationTabs.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Configurations, ServerTab } from '@/lib/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 { useState } from 'react';
|
||||
import { StateHistoryStats } from '@/lib/schemas';
|
||||
|
||||
interface ServerConfigurationTabsProps {
|
||||
serverId: string;
|
||||
configurations: Configurations;
|
||||
statistics: StateHistoryStats;
|
||||
}
|
||||
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: '🔧' }
|
||||
];
|
||||
|
||||
export function ServerConfigurationTabs({
|
||||
serverId,
|
||||
configurations,
|
||||
statistics
|
||||
}: ServerConfigurationTabsProps) {
|
||||
const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (currentTab) {
|
||||
case ServerTab.statistics:
|
||||
return <StatisticsDashboard stats={statistics} />;
|
||||
|
||||
case ServerTab.configuration:
|
||||
return <ConfigurationEditor serverId={serverId} config={configurations.configuration} />;
|
||||
|
||||
case ServerTab.assistRules:
|
||||
return <AssistRulesEditor serverId={serverId} config={configurations.assistRules} />;
|
||||
|
||||
case ServerTab.event:
|
||||
return <EventConfigEditor serverId={serverId} config={configurations.event} />;
|
||||
|
||||
case ServerTab.eventRules:
|
||||
return <EventRulesEditor serverId={serverId} config={configurations.eventRules} />;
|
||||
|
||||
case ServerTab.settings:
|
||||
return <ServerSettingsEditor serverId={serverId} config={configurations.settings} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-4 text-6xl">🚧</div>
|
||||
<h3 className="mb-2 text-xl font-semibold text-white">Tab Not Found</h3>
|
||||
<p className="text-gray-400">The requested tab could not be found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg bg-gray-800">
|
||||
<div className="border-b border-gray-700">
|
||||
<nav className="flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = currentTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setCurrentTab(tab.id)}
|
||||
className={`flex items-center space-x-2 border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300'
|
||||
} `}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span>{tab.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">{renderTabContent()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
src/components/server/ServerCreationPopup.tsx
Normal file
477
src/components/server/ServerCreationPopup.tsx
Normal 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 = () => {
|
||||
if (consoleRef.current && !isMinimized && isConsoleVisible) {
|
||||
consoleRef.current.scrollTop = consoleRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [entries, isMinimized, isConsoleVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId && isOpen) {
|
||||
associateWithServer(serverId);
|
||||
}
|
||||
}, [serverId, isOpen, associateWithServer]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: WebSocketMessage) => {
|
||||
if (message.server_id !== serverId) return;
|
||||
|
||||
const timestamp = message.timestamp;
|
||||
|
||||
switch (message.type) {
|
||||
case 'step': {
|
||||
const data = message.data as StepData;
|
||||
setSteps((prev) => {
|
||||
const updatedSteps = { ...prev };
|
||||
|
||||
const stepIndex = STEPS.findIndex((step) => step.key === data.step);
|
||||
if (stepIndex > 0) {
|
||||
for (let i = 0; i < stepIndex; i++) {
|
||||
const prevStepKey = STEPS[i].key;
|
||||
if (!updatedSteps[prevStepKey] || updatedSteps[prevStepKey].status === 'pending') {
|
||||
updatedSteps[prevStepKey] = {
|
||||
step: prevStepKey,
|
||||
status: 'completed',
|
||||
message: `${prevStepKey.replace('_', ' ')} completed`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedSteps[data.step] = {
|
||||
step: data.step,
|
||||
status: data.status,
|
||||
message: data.message
|
||||
};
|
||||
|
||||
return updatedSteps;
|
||||
});
|
||||
|
||||
let level: ConsoleEntry['level'] = 'info';
|
||||
if (data.status === 'completed') level = 'success';
|
||||
else if (data.status === 'failed') level = 'error';
|
||||
else if (data.status === 'in_progress') level = 'warning';
|
||||
|
||||
addEntry({
|
||||
timestamp,
|
||||
type: 'step',
|
||||
content: `[${data.step.toUpperCase()}] ${data.message}${data.error ? ` - ${data.error}` : ''}`,
|
||||
level
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'steam_output': {
|
||||
const data = message.data as SteamOutputData;
|
||||
|
||||
setSteps((prev) => {
|
||||
const updatedSteps = { ...prev };
|
||||
|
||||
if (
|
||||
!updatedSteps['steam_download'] ||
|
||||
updatedSteps['steam_download'].status === 'pending'
|
||||
) {
|
||||
updatedSteps['steam_download'] = {
|
||||
step: 'steam_download',
|
||||
status: 'in_progress',
|
||||
message: 'Steam download in progress'
|
||||
};
|
||||
}
|
||||
|
||||
if (!updatedSteps['validation'] || updatedSteps['validation'].status === 'pending') {
|
||||
updatedSteps['validation'] = {
|
||||
step: 'validation',
|
||||
status: 'completed',
|
||||
message: 'Validation completed'
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!updatedSteps['directory_creation'] ||
|
||||
updatedSteps['directory_creation'].status === 'pending'
|
||||
) {
|
||||
updatedSteps['directory_creation'] = {
|
||||
step: 'directory_creation',
|
||||
status: 'completed',
|
||||
message: 'Directory creation completed'
|
||||
};
|
||||
}
|
||||
|
||||
return updatedSteps;
|
||||
});
|
||||
|
||||
addEntry({
|
||||
timestamp,
|
||||
type: 'steam_output',
|
||||
content: data.output,
|
||||
level: data.is_error ? 'error' : 'info'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const data = message.data as ErrorData;
|
||||
|
||||
setSteps((prev) => {
|
||||
const updatedSteps = { ...prev };
|
||||
const currentStep = Object.values(updatedSteps).find(
|
||||
(step) => step.status === 'in_progress'
|
||||
);
|
||||
if (currentStep) {
|
||||
updatedSteps[currentStep.step] = {
|
||||
...currentStep,
|
||||
status: 'failed',
|
||||
message: `${currentStep.step.replace('_', ' ')} failed: ${data.error}`
|
||||
};
|
||||
}
|
||||
return updatedSteps;
|
||||
});
|
||||
|
||||
addEntry({
|
||||
timestamp,
|
||||
type: 'error',
|
||||
content: `ERROR: ${data.error}${data.details ? ` - ${data.details}` : ''}`,
|
||||
level: 'error'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
const data = message.data as CompleteData;
|
||||
setIsCompleted(true);
|
||||
setCompletionResult({ success: data.success, message: data.message });
|
||||
|
||||
addEntry({
|
||||
timestamp,
|
||||
type: 'complete',
|
||||
content: `COMPLETED: ${data.message}`,
|
||||
level: data.success ? 'success' : 'error'
|
||||
});
|
||||
|
||||
onComplete?.(data.success, data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[serverId, addEntry, onComplete]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
addMessageHandler(handleMessage);
|
||||
return () => {
|
||||
removeMessageHandler(handleMessage);
|
||||
};
|
||||
}
|
||||
}, [addMessageHandler, removeMessageHandler, handleMessage, isOpen]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
try {
|
||||
await reconnect();
|
||||
} catch (error) {
|
||||
console.error('Failed to reconnect:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepStatusIcon = (status: StepStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '⏳';
|
||||
case 'in_progress':
|
||||
return '🔄';
|
||||
case 'completed':
|
||||
return '✅';
|
||||
case 'failed':
|
||||
return '❌';
|
||||
}
|
||||
};
|
||||
|
||||
const getEntryClassName = (level: ConsoleEntry['level']) => {
|
||||
switch (level) {
|
||||
case 'success':
|
||||
return 'text-green-400';
|
||||
case 'warning':
|
||||
return 'text-yellow-400';
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatusIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return '🟢';
|
||||
case 'connecting':
|
||||
return '🟡';
|
||||
case 'disconnected':
|
||||
return '⚫';
|
||||
case 'error':
|
||||
return '🔴';
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentProgress = () => {
|
||||
const completedSteps = Object.values(steps).filter(
|
||||
(step) => step.status === 'completed'
|
||||
).length;
|
||||
const totalSteps = STEPS.length;
|
||||
return { completed: completedSteps, total: totalSteps };
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (isMinimized) {
|
||||
const progress = getCurrentProgress();
|
||||
const isProgressing =
|
||||
!isCompleted && Object.values(steps).some((step) => step.status === 'in_progress');
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 z-40">
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-full border-2 shadow-lg transition-all hover:scale-105 ${
|
||||
isCompleted
|
||||
? completionResult?.success
|
||||
? 'border-green-400 bg-green-600 hover:bg-green-700'
|
||||
: 'border-red-400 bg-red-600 hover:bg-red-700'
|
||||
: 'border-blue-400 bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
title={`Server Creation: ${serverName} - ${progress.completed}/${progress.total} steps`}
|
||||
>
|
||||
<div className="text-center text-white">
|
||||
{isCompleted ? (
|
||||
<span className="text-2xl">{completionResult?.success ? '✅' : '❌'}</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-bold">
|
||||
{progress.completed}/{progress.total}
|
||||
</div>
|
||||
{isProgressing && <div className="animate-pulse text-xs">⚡</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCompleted && (
|
||||
<svg className="absolute inset-0 h-16 w-16 -rotate-90 transform">
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
className="text-gray-300 opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray={`${(progress.completed / progress.total) * 175.929} 175.929`}
|
||||
className="text-white transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 z-40 max-h-[600px] w-96 rounded-lg border border-gray-700 bg-gray-800 shadow-2xl select-none">
|
||||
<div className="flex items-center justify-between border-b border-gray-700 p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">🔧</span>
|
||||
<h3 className="truncate font-medium text-white">{serverName}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm">{getConnectionStatusIcon()}</span>
|
||||
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="rounded bg-blue-600 px-1 py-0.5 text-xs hover:bg-blue-700"
|
||||
title="Reconnect"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsConsoleVisible(!isConsoleVisible)}
|
||||
className="text-sm text-gray-400 hover:text-white"
|
||||
title={isConsoleVisible ? 'Hide Console' : 'Show Console'}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
title="Minimize"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white" title="Close">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-700 p-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{STEPS.map(({ key, label }) => {
|
||||
const stepStatus = steps[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center space-x-2 rounded p-2 text-xs ${
|
||||
stepStatus?.status === 'completed'
|
||||
? 'bg-green-900/50 text-green-100'
|
||||
: stepStatus?.status === 'in_progress'
|
||||
? 'bg-yellow-900/50 text-yellow-100'
|
||||
: stepStatus?.status === 'failed'
|
||||
? 'bg-red-900/50 text-red-100'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{getStepStatusIcon(stepStatus?.status || 'pending')}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConsoleVisible && (
|
||||
<div className="h-64 bg-black">
|
||||
<div ref={consoleRef} className="h-full space-y-1 overflow-y-auto p-3 font-mono text-xs">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className={`${getEntryClassName(entry.level)} leading-tight`}>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(entry.timestamp * 1000).toLocaleTimeString()}
|
||||
</span>{' '}
|
||||
<span>{entry.content}</span>
|
||||
</div>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
Waiting for server creation to begin...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div
|
||||
className={`p-3 text-center text-sm ${
|
||||
completionResult?.success
|
||||
? 'bg-green-900/50 text-green-100'
|
||||
: 'bg-red-900/50 text-red-100'
|
||||
}`}
|
||||
>
|
||||
{completionResult?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/server/ServerCreationPopupContainer.tsx
Normal file
36
src/components/server/ServerCreationPopupContainer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
161
src/components/server/ServerHeader.tsx
Normal file
161
src/components/server/ServerHeader.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { Server, getStatusColor, 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';
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg bg-gray-800 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center text-gray-400 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">{server.name}</h1>
|
||||
<div className="mt-2 flex items-center">
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${getStatusColor(server.status)} mr-3`}
|
||||
/>
|
||||
<span className="text-lg text-gray-300 capitalize">
|
||||
{serviceStatusToString(server.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
{canDeleteServer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
disabled={disabled || isPending}
|
||||
className="mr-3 rounded bg-red-800 px-4 py-2 font-medium text-white transition-colors hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Remove Server
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startServer}
|
||||
disabled={server.status === ServiceStatus.Running || disabled || isPending}
|
||||
className="rounded bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={restartServer}
|
||||
disabled={server.status === ServiceStatus.Stopped || disabled || isPending}
|
||||
className="rounded bg-yellow-600 px-4 py-2 font-medium text-white transition-colors hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopServer}
|
||||
disabled={server.status === ServiceStatus.Stopped || disabled || isPending}
|
||||
className="rounded bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Current Track</div>
|
||||
<div className="text-lg font-medium text-white">{server.state?.track || 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Players</div>
|
||||
<div className="text-lg font-medium text-white">
|
||||
{server.state?.playerCount || 0} / {server.state?.maxConnections || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Session</div>
|
||||
<div className="text-lg font-medium text-white">{server.state?.session || 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Max Connections</div>
|
||||
<div className="text-lg font-medium text-white">
|
||||
{server.state?.maxConnections || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteServerModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
server={server}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/server/ServerListWithActions.tsx
Normal file
51
src/components/server/ServerListWithActions.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Server } from '@/lib/schemas/server';
|
||||
import { User, hasPermission } from '@/lib/schemas/user';
|
||||
import { ServerCard } from './ServerCard';
|
||||
import { CreateServerModal } from './CreateServerModal';
|
||||
import RefreshButton from '@/components/ui/RefreshButton';
|
||||
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
|
||||
|
||||
interface ServerListWithActionsProps {
|
||||
servers: Server[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function ServerListWithActions({ servers, user }: ServerListWithActionsProps) {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const { isSteamCMDRunning } = useSteamCMD();
|
||||
|
||||
const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []);
|
||||
const canCreateServer = hasPermission(user, 'server.create');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Your Servers</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
{canCreateServer && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
disabled={isSteamCMDRunning}
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={isSteamCMDRunning ? 'Server creation disabled while SteamCMD is running' : ''}
|
||||
>
|
||||
Create Server
|
||||
</button>
|
||||
)}
|
||||
<RefreshButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{servers.map((server) => (
|
||||
<ServerCard key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreateServerModal isOpen={isCreateModalOpen} onClose={handleOnClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/components/statistics/DailyActivityChart.tsx
Normal file
77
src/components/statistics/DailyActivityChart.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
sessionsCount: number;
|
||||
}
|
||||
|
||||
interface DailyActivityChartProps {
|
||||
data: DailyActivity[];
|
||||
}
|
||||
|
||||
export function DailyActivityChart({ data }: DailyActivityChartProps) {
|
||||
const chartData = {
|
||||
labels: data.map((item) => new Date(item.date).toLocaleDateString()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Sessions',
|
||||
data: data.map((item) => item.sessionsCount),
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.8)',
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#e5e7eb'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<Bar data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/statistics/PlayerCountChart.tsx
Normal file
95
src/components/statistics/PlayerCountChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
} from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
);
|
||||
|
||||
interface PlayerCountPoint {
|
||||
timestamp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PlayerCountChartProps {
|
||||
data: PlayerCountPoint[];
|
||||
}
|
||||
|
||||
export function PlayerCountChart({ data }: PlayerCountChartProps) {
|
||||
const chartData = {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Player Count',
|
||||
data: data.map((point) => ({
|
||||
x: new Date(point.timestamp),
|
||||
y: point.count
|
||||
})),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#e5e7eb'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time' as const,
|
||||
time: {
|
||||
unit: 'hour' as const
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<Line data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/statistics/RecentSessions.tsx
Normal file
85
src/components/statistics/RecentSessions.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
interface RecentSession {
|
||||
id: 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="py-8 text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<p className="text-gray-400">No recent sessions found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Track
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase">
|
||||
Players
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{sessions.map((session) => (
|
||||
<tr key={session.id} className="transition-colors hover:bg-gray-700">
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDate(session.date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-900 px-2.5 py-0.5 text-xs font-medium text-blue-300">
|
||||
{session.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">{session.track}</td>
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{formatDuration(session.duration)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-300">
|
||||
{session.players}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/statistics/SessionTypesChart.tsx
Normal file
67
src/components/statistics/SessionTypesChart.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||
|
||||
interface SessionCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SessionTypesChartProps {
|
||||
data: SessionCount[];
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // emerald
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#06b6d4' // cyan
|
||||
];
|
||||
|
||||
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: '#e5e7eb',
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: { label: string; parsed: number }) {
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0);
|
||||
const percentage = ((context.parsed / total) * 100).toFixed(1);
|
||||
return `${context.label}: ${context.parsed} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<Doughnut data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/statistics/StatCard.tsx
Normal file
21
src/components/statistics/StatCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-3xl">{icon}</span>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-2xl font-semibold text-white">{value}</div>
|
||||
<div className="text-sm text-gray-400">{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/statistics/StatisticsDashboard.tsx
Normal file
67
src/components/statistics/StatisticsDashboard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import type { StateHistoryStats } from '@/lib/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-12 text-center">
|
||||
<div className="mb-4 text-6xl">📊</div>
|
||||
<h3 className="mb-2 text-xl font-semibold text-white">No Statistics Available</h3>
|
||||
<p className="text-gray-400">No data found for the selected date range.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title="Total Sessions" value={stats.totalSessions ?? 0} icon="🏁" />
|
||||
<StatCard
|
||||
title="Total Playtime"
|
||||
value={`${Math.round(stats.totalPlaytime ?? 0 / 60)}h`}
|
||||
icon="⏱️"
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Players"
|
||||
value={(stats.averagePlayers ?? 0).toFixed(1)}
|
||||
icon="👥"
|
||||
/>
|
||||
<StatCard title="Peak Players" value={stats.peakPlayers ?? 0} icon="🔥" />
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-9 rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-medium text-white">Player Count Over Time</h3>
|
||||
<PlayerCountChart data={stats.playerCountOverTime ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-medium text-white">Session Types</h3>
|
||||
<SessionTypesChart data={stats.sessionTypes ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-medium text-white">Daily Activity</h3>
|
||||
<DailyActivityChart data={stats.dailyActivity ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
<div className="rounded-lg bg-gray-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-medium text-white">Recent Sessions</h3>
|
||||
<RecentSessions sessions={stats.recentSessions ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-300 hover:text-white hover:bg-gray-700 focus:ring-gray-500'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props },
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(baseClasses, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || isLoading}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
29
src/components/ui/Input.tsx
Normal file
29
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
|
||||
<input
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
24
src/components/ui/LoadingSpinner.tsx
Normal file
24
src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-gray-300 border-t-transparent',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/LogoutButton.tsx
Normal file
29
src/components/ui/LogoutButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { logoutAction } from '@/lib/actions/auth';
|
||||
import { useActionState } from 'react';
|
||||
|
||||
export default function LogoutButton() {
|
||||
const [, formAction] = useActionState(logoutAction, null);
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<button type="submit" className="flex items-center text-gray-300 hover:text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-1 hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/Modal.tsx
Normal file
54
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="bg-opacity-50 absolute inset-0 bg-black" onClick={onClose} />
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-4 w-full max-w-md rounded-lg bg-gray-800 p-6 shadow-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/ui/Pagination.tsx
Normal file
73
src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const getVisiblePages = () => {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(2, currentPage - delta);
|
||||
i <= Math.min(totalPages - 1, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
if (currentPage - delta > 2) {
|
||||
range.unshift('...');
|
||||
}
|
||||
if (currentPage + delta < totalPages - 1) {
|
||||
range.push('...');
|
||||
}
|
||||
|
||||
range.unshift(1);
|
||||
if (totalPages !== 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-1">
|
||||
{getVisiblePages().map((page, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => (typeof page === 'number' ? onPageChange(page) : undefined)}
|
||||
disabled={typeof page === 'string'}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: page === '...'
|
||||
? 'cursor-default text-gray-400'
|
||||
: 'bg-gray-700 text-white hover:bg-gray-600'
|
||||
} `}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-md bg-gray-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
15
src/components/ui/RefreshButton.tsx
Normal file
15
src/components/ui/RefreshButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function RefreshButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.refresh()}
|
||||
className="rounded-md bg-gray-700 px-3 py-1 text-sm hover:bg-gray-600"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/Select.tsx
Normal file
36
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, SelectHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string | number; label: string }[];
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="block text-sm font-medium text-gray-300">{label}</label>}
|
||||
<select
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
46
src/components/ui/SteamCMDNotification.tsx
Normal file
46
src/components/ui/SteamCMDNotification.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useSteamCMD } from '@/lib/context/SteamCMDContext';
|
||||
|
||||
export function SteamCMDNotification() {
|
||||
const { isSteamCMDRunning, runningSteamServers } = useSteamCMD();
|
||||
|
||||
if (!isSteamCMDRunning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverCount = runningSteamServers.size;
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-600 border-l-4 border-yellow-400 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-50"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-50">
|
||||
<strong>SteamCMD is currently running</strong> for {serverCount} server{serverCount !== 1 ? 's' : ''}.
|
||||
Server actions are temporarily disabled to prevent conflicts.
|
||||
This will automatically resolve when the download completes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-yellow-50 border-t-transparent rounded-full mr-2"></div>
|
||||
<span className="text-xs text-yellow-50">Downloading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/Toast.tsx
Normal file
49
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, type, duration = 5000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed top-4 right-4 z-50 max-w-md rounded-md p-4 shadow-lg', {
|
||||
'bg-green-600 text-white': type === 'success',
|
||||
'bg-red-600 text-white': type === 'error',
|
||||
'bg-blue-600 text-white': type === 'info'
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}}
|
||||
className="ml-4 text-white hover:opacity-80"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/websocket/WebSocketInitializer.tsx
Normal file
25
src/components/websocket/WebSocketInitializer.tsx
Normal 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);
|
||||
};
|
||||
53
src/lib/actions/auth.ts
Normal file
53
src/lib/actions/auth.ts
Normal 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();
|
||||
}
|
||||
234
src/lib/actions/configuration.ts
Normal file
234
src/lib/actions/configuration.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { updateServerConfiguration } from '@/lib/api/server/configuration';
|
||||
import {
|
||||
assistRulesSchema,
|
||||
ConfigFile,
|
||||
eventConfigSchema,
|
||||
eventRulesSchema,
|
||||
serverSettingsSchema
|
||||
} from '@/lib/schemas/config';
|
||||
import type {
|
||||
Configuration,
|
||||
AssistRules,
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
48
src/lib/actions/membership.ts
Normal file
48
src/lib/actions/membership.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
64
src/lib/actions/server-management.ts
Normal file
64
src/lib/actions/server-management.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { createServer, deleteServer } from '@/lib/api/server/servers';
|
||||
|
||||
export type ServerActionData = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ServerActionResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: ServerActionData;
|
||||
};
|
||||
|
||||
export async function createServerAction(
|
||||
prevState: ServerActionResult,
|
||||
formData: FormData
|
||||
): Promise<ServerActionResult> {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
if (!name?.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Server name is required'
|
||||
};
|
||||
}
|
||||
|
||||
const server = await createServer(session.token!, name.trim());
|
||||
revalidatePath('/dashboard');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Server creation started',
|
||||
data: server
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to create server'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServerAction(serverId: string): Promise<ServerActionResult> {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
await deleteServer(session.token!, serverId);
|
||||
revalidatePath('/dashboard');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Server deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to delete server'
|
||||
};
|
||||
}
|
||||
}
|
||||
62
src/lib/actions/servers.ts
Normal file
62
src/lib/actions/servers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { requireAuth } from '@/lib/auth/server';
|
||||
import { startService, stopService, restartService } from '@/lib/api/server/servers';
|
||||
|
||||
export async function startServerAction(serverId: string) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
await startService(session.token!, serverId);
|
||||
revalidatePath('/dashboard');
|
||||
revalidatePath(`/dashboard/server/${serverId}`);
|
||||
revalidateTag('/server');
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to start server'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServerEventAction(serverId: string) {
|
||||
await startServerAction(serverId);
|
||||
}
|
||||
|
||||
export async function stopServerAction(serverId: string) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
await stopService(session.token!, serverId);
|
||||
revalidatePath('/dashboard');
|
||||
revalidatePath(`/dashboard/server/${serverId}`);
|
||||
revalidateTag('/server');
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to stop server'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServerEventAction(serverId: string) {
|
||||
await stopServerAction(serverId);
|
||||
}
|
||||
|
||||
export async function restartServerAction(serverId: string) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
await restartService(session.token!, serverId);
|
||||
revalidatePath('/dashboard');
|
||||
revalidatePath(`/dashboard/server/${serverId}`);
|
||||
revalidateTag('/server');
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to restart server'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServerEventAction(serverId: string) {
|
||||
await restartServerAction(serverId);
|
||||
}
|
||||
63
src/lib/api/client/base.ts
Normal file
63
src/lib/api/client/base.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { SessionData } from '@/lib/session/config';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export type ClientApiResponse<T> = {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const getSession = async (): Promise<SessionData | null> => {
|
||||
const response = await fetch('/api/session');
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function fetchClientAPI<T>(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: object,
|
||||
customToken?: string
|
||||
): Promise<ClientApiResponse<T>> {
|
||||
let token = customToken;
|
||||
let session: SessionData | null = null;
|
||||
|
||||
if (!token) {
|
||||
session = await getSession();
|
||||
token = session?.openToken;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login?expired=true';
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`);
|
||||
}
|
||||
|
||||
if (response.headers.get('Content-Type')?.includes('application/json')) {
|
||||
return { data: await response.json() };
|
||||
}
|
||||
|
||||
return { message: await response.text() };
|
||||
}
|
||||
73
src/lib/api/client/hooks.ts
Normal file
73
src/lib/api/client/hooks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
async function fetchFromAPI(endpoint: string, method: string = 'GET', body?: unknown) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function useServerStatus(serverId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['server-status', serverId],
|
||||
queryFn: () => fetchFromAPI(`/api/server/${serverId}/status`),
|
||||
refetchInterval: 30000
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerAction(serverId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ action }: { action: 'start' | 'stop' | 'restart' }) =>
|
||||
fetchFromAPI(`/api/server/${serverId}/action`, 'POST', { action }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server-status', serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsers(params?: Record<string, string>) {
|
||||
const searchParams = new URLSearchParams(params).toString();
|
||||
const endpoint = `/api/membership${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['users', params],
|
||||
queryFn: () => fetchFromAPI(endpoint)
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userData: { username: string; password: string; role: string }) =>
|
||||
fetchFromAPI('/api/membership', 'POST', userData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => fetchFromAPI(`/api/membership/${userId}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}
|
||||
});
|
||||
}
|
||||
12
src/lib/api/client/query-client.ts
Normal file
12
src/lib/api/client/query-client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
55
src/lib/api/server/auth.ts
Normal file
55
src/lib/api/server/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Login,
|
||||
LoginResponse,
|
||||
loginResponseSchema,
|
||||
loginSchema,
|
||||
loginTokenSchema,
|
||||
User,
|
||||
userSchema
|
||||
} from '@/lib/schemas';
|
||||
import { fetchServerAPI } from './base';
|
||||
|
||||
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
const authRoute = '/auth';
|
||||
|
||||
export async function loginUser(login: Login): Promise<LoginResponse> {
|
||||
const validatedLogin = loginSchema.parse(login);
|
||||
const response = await fetch(`${BASE_URL}${authRoute}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(validatedLogin)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error(`Invalid credentials`);
|
||||
}
|
||||
|
||||
throw new Error(`Login failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const { token } = loginTokenSchema.parse(rawData);
|
||||
|
||||
const userResponse = await getCurrentUser(token);
|
||||
|
||||
return loginResponseSchema.parse({ token, user: userResponse });
|
||||
}
|
||||
|
||||
export async function getCurrentUser(token: string): Promise<User> {
|
||||
const response = await fetchServerAPI<User>(`${authRoute}/me`, token);
|
||||
return userSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export async function getOpenToken(token: string): Promise<string> {
|
||||
const response = await fetchServerAPI<{ token: string }>(
|
||||
`${authRoute}/open-token`,
|
||||
token,
|
||||
'POST'
|
||||
);
|
||||
return loginTokenSchema.parse(response.data).token;
|
||||
}
|
||||
43
src/lib/api/server/base.ts
Normal file
43
src/lib/api/server/base.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function fetchServerAPI<T>(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
method: string = 'GET',
|
||||
body?: object
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
next: { tags: [endpoint] }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status == 401) {
|
||||
redirect('/login?expired=true');
|
||||
}
|
||||
throw new Error(
|
||||
`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint} - ${token}`
|
||||
);
|
||||
}
|
||||
|
||||
if (response.headers.get('Content-Type')?.includes('application/json')) {
|
||||
return { data: await response.json() };
|
||||
}
|
||||
|
||||
return { message: await response.text() };
|
||||
}
|
||||
52
src/lib/api/server/configuration.ts
Normal file
52
src/lib/api/server/configuration.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { fetchServerAPI } from './base';
|
||||
import {
|
||||
type Configurations,
|
||||
type Config,
|
||||
ConfigFile,
|
||||
configurationsSchema,
|
||||
configSchemaMap
|
||||
} from '@/lib/schemas/config';
|
||||
import * as z from 'zod';
|
||||
|
||||
const serverRoute = '/server';
|
||||
|
||||
export async function getServerConfigurations(
|
||||
token: string,
|
||||
serverId: string
|
||||
): Promise<Configurations> {
|
||||
const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
|
||||
return configurationsSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export function validateConfig(
|
||||
configType: ConfigFile,
|
||||
data: unknown
|
||||
): z.infer<(typeof configSchemaMap)[typeof configType]> {
|
||||
const schema = configSchemaMap[configType];
|
||||
return schema.parse(data);
|
||||
}
|
||||
|
||||
export async function getServerConfiguration(
|
||||
token: string,
|
||||
serverId: string,
|
||||
configType: ConfigFile
|
||||
): Promise<Config> {
|
||||
const response = await fetchServerAPI<Config>(
|
||||
`${serverRoute}/${serverId}/config/${configType}`,
|
||||
token
|
||||
);
|
||||
return validateConfig(configType, response.data);
|
||||
}
|
||||
|
||||
export async function updateServerConfiguration(
|
||||
token: string,
|
||||
serverId: string,
|
||||
configType: ConfigFile,
|
||||
config: Config,
|
||||
restart = false
|
||||
): Promise<void> {
|
||||
await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}?override=true`, token, 'PUT', {
|
||||
...validateConfig(configType, config),
|
||||
restart
|
||||
});
|
||||
}
|
||||
43
src/lib/api/server/lookups.ts
Normal file
43
src/lib/api/server/lookups.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { fetchServerAPI } from './base';
|
||||
import {
|
||||
Track,
|
||||
CarModel,
|
||||
CupCategory,
|
||||
DriverCategory,
|
||||
SessionType,
|
||||
trackSchema,
|
||||
carModelSchema,
|
||||
cupCategorySchema,
|
||||
driverCategorySchema,
|
||||
sessionTypeSchema
|
||||
} from '@/lib/schemas';
|
||||
|
||||
const lookupRoute = '/lookup';
|
||||
|
||||
export async function getTracks(token: string): Promise<Track[]> {
|
||||
const response = await fetchServerAPI<Track[]>(`${lookupRoute}/tracks`, token);
|
||||
return trackSchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function getCarModels(token: string): Promise<CarModel[]> {
|
||||
const response = await fetchServerAPI<CarModel[]>(`${lookupRoute}/car-models`, token);
|
||||
return carModelSchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function getCupCategories(token: string): Promise<CupCategory[]> {
|
||||
const response = await fetchServerAPI<CupCategory[]>(`${lookupRoute}/cup-categories`, token);
|
||||
return cupCategorySchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function getDriverCategories(token: string): Promise<DriverCategory[]> {
|
||||
const response = await fetchServerAPI<DriverCategory[]>(
|
||||
`${lookupRoute}/driver-categories`,
|
||||
token
|
||||
);
|
||||
return driverCategorySchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function getSessionTypes(token: string): Promise<SessionType[]> {
|
||||
const response = await fetchServerAPI<SessionType[]>(`${lookupRoute}/session-types`, token);
|
||||
return sessionTypeSchema.array().parse(response.data);
|
||||
}
|
||||
61
src/lib/api/server/membership.ts
Normal file
61
src/lib/api/server/membership.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { use } from 'react';
|
||||
import { fetchServerAPI } from './base';
|
||||
import { User, Role, userSchema, UserCreate, userCreateSchema, roleSchema } from '@/lib/schemas';
|
||||
|
||||
export interface UserListParams {
|
||||
username?: string;
|
||||
role_name?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const membershipRoute = '/membership';
|
||||
|
||||
export async function getUsers(token: string, params: UserListParams = {}): Promise<User[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.set(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const endpoint = `${membershipRoute}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetchServerAPI<User[]>(endpoint, token);
|
||||
return userSchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function createUser(token: string, userData: UserCreate): Promise<void> {
|
||||
await fetchServerAPI(membershipRoute, token, 'POST', userCreateSchema.parse(userData));
|
||||
}
|
||||
|
||||
export async function getUserById(token: string, userId: string): Promise<User> {
|
||||
const response = await fetchServerAPI<User>(`${membershipRoute}/${userId}`, token);
|
||||
return userSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
token: string,
|
||||
userId: string,
|
||||
userData: Partial<UserCreate>
|
||||
): Promise<void> {
|
||||
await fetchServerAPI(
|
||||
`${membershipRoute}/${userId}`,
|
||||
token,
|
||||
'PUT',
|
||||
userCreateSchema.parse(userData)
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUser(token: string, userId: string): Promise<void> {
|
||||
await fetchServerAPI(`${membershipRoute}/${userId}`, token, 'DELETE');
|
||||
}
|
||||
|
||||
export async function getRoles(token: string): Promise<Role[]> {
|
||||
const response = await fetchServerAPI<Role[]>(`${membershipRoute}/roles`, token);
|
||||
return roleSchema.array().parse(response.data);
|
||||
}
|
||||
40
src/lib/api/server/servers.ts
Normal file
40
src/lib/api/server/servers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { fetchServerAPI } from './base';
|
||||
import { Server, serverSchema, ServiceStatus, serviceStatusSchema } from '@/lib/schemas/server';
|
||||
|
||||
const serverRoute = '/server';
|
||||
|
||||
export async function getServers(token: string): Promise<Server[]> {
|
||||
const response = await fetchServerAPI<Server[]>(serverRoute, token);
|
||||
return serverSchema.array().parse(response.data);
|
||||
}
|
||||
|
||||
export async function getServer(token: string, serverId: string): Promise<Server> {
|
||||
const response = await fetchServerAPI<Server>(`${serverRoute}/${serverId}`, token);
|
||||
return serverSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export async function restartService(token: string, serverId: string): Promise<void> {
|
||||
await fetchServerAPI(`${serverRoute}/${serverId}/service/restart`, token, 'POST');
|
||||
}
|
||||
|
||||
export async function startService(token: string, serverId: string): Promise<void> {
|
||||
await fetchServerAPI(`${serverRoute}/${serverId}/service/start`, token, 'POST');
|
||||
}
|
||||
|
||||
export async function stopService(token: string, serverId: string): Promise<void> {
|
||||
await fetchServerAPI(`${serverRoute}/${serverId}/service/stop`, token, 'POST');
|
||||
}
|
||||
|
||||
export async function getServiceStatus(token: string, serverId: string): Promise<ServiceStatus> {
|
||||
const response = await fetchServerAPI<ServiceStatus>(`${serverRoute}/${serverId}/service`, token);
|
||||
return serviceStatusSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export async function createServer(token: string, name: string): Promise<Server> {
|
||||
const response = await fetchServerAPI<Server>(serverRoute, token, 'POST', { name });
|
||||
return serverSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export async function deleteServer(token: string, serverId: string): Promise<void> {
|
||||
await fetchServerAPI(`${serverRoute}/${serverId}`, token, 'DELETE');
|
||||
}
|
||||
22
src/lib/api/server/statistics.ts
Normal file
22
src/lib/api/server/statistics.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fetchServerAPI } from './base';
|
||||
import {
|
||||
StateHistoryStatsFilter,
|
||||
stateHistoryStatsFilterSchema,
|
||||
stateHistoryStatsSchema,
|
||||
type StateHistoryStats
|
||||
} from '@/lib/schemas/statistics';
|
||||
|
||||
const serverRoute = '/server';
|
||||
|
||||
export async function getServerStatistics(
|
||||
token: string,
|
||||
serverId: string,
|
||||
filters: StateHistoryStatsFilter
|
||||
): Promise<StateHistoryStats> {
|
||||
const { startDate, endDate } = stateHistoryStatsFilterSchema.parse(filters);
|
||||
const response = await fetchServerAPI<StateHistoryStats>(
|
||||
`${serverRoute}/${serverId}/state-history/statistics?start_date=${startDate}&end_date=${endDate}`,
|
||||
token
|
||||
);
|
||||
return stateHistoryStatsSchema.parse(response.data);
|
||||
}
|
||||
33
src/lib/auth/server.ts
Normal file
33
src/lib/auth/server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getIronSession } from 'iron-session';
|
||||
import { cookies } from 'next/headers';
|
||||
import { SessionData, sessionOptions } from '@/lib/session/config';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function getSession() {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAuth(skipRedirect?: boolean) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!skipRedirect && (!session.token || !session.user)) {
|
||||
redirect('/login?expired=true');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function login(token: string, user: SessionData['user'], openToken?: string) {
|
||||
const session = await getSession();
|
||||
session.token = token;
|
||||
session.user = user;
|
||||
session.openToken = openToken;
|
||||
await session.save();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
'use server';
|
||||
const session = await getSession();
|
||||
session.destroy();
|
||||
}
|
||||
61
src/lib/context/ServerCreationPopupContext.tsx
Normal file
61
src/lib/context/ServerCreationPopupContext.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
|
||||
interface ServerCreationPopupState {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface ServerCreationPopupContextType {
|
||||
popup: ServerCreationPopupState | null;
|
||||
showPopup: (serverId: string, serverName: string) => void;
|
||||
hidePopup: () => void;
|
||||
isPopupOpen: boolean;
|
||||
}
|
||||
|
||||
const ServerCreationPopupContext = createContext<ServerCreationPopupContextType | null>(null);
|
||||
|
||||
export function useServerCreationPopup() {
|
||||
const context = useContext(ServerCreationPopupContext);
|
||||
if (!context) {
|
||||
throw new Error('useServerCreationPopup must be used within a ServerCreationPopupProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ServerCreationPopupProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ServerCreationPopupProvider({ children }: ServerCreationPopupProviderProps) {
|
||||
const [popup, setPopup] = useState<ServerCreationPopupState | null>(null);
|
||||
|
||||
const showPopup = useCallback((serverId: string, serverName: string) => {
|
||||
setPopup({
|
||||
serverId,
|
||||
serverName,
|
||||
isOpen: true
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
setPopup(null);
|
||||
}, []);
|
||||
|
||||
const isPopupOpen = popup?.isOpen || false;
|
||||
|
||||
return (
|
||||
<ServerCreationPopupContext.Provider
|
||||
value={{
|
||||
popup,
|
||||
showPopup,
|
||||
hidePopup,
|
||||
isPopupOpen
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ServerCreationPopupContext.Provider>
|
||||
);
|
||||
}
|
||||
79
src/lib/context/SteamCMDContext.tsx
Normal file
79
src/lib/context/SteamCMDContext.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
|
||||
import { useWebSocket } from '@/lib/websocket/context';
|
||||
import type { WebSocketMessage, StepData } from '@/lib/schemas/websocket';
|
||||
|
||||
interface SteamCMDContextType {
|
||||
isSteamCMDRunning: boolean;
|
||||
runningSteamServers: Set<string>;
|
||||
dissociateServer: (serverId: string) => void;
|
||||
}
|
||||
|
||||
const SteamCMDContext = createContext<SteamCMDContextType | null>(null);
|
||||
|
||||
export function useSteamCMD() {
|
||||
const context = useContext(SteamCMDContext);
|
||||
if (!context) {
|
||||
throw new Error('useSteamCMD must be used within a SteamCMDProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SteamCMDProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SteamCMDProvider({ children }: SteamCMDProviderProps) {
|
||||
const [runningSteamServers, setRunningSteamServers] = useState<Set<string>>(new Set());
|
||||
const { addMessageHandler, removeMessageHandler } = useWebSocket();
|
||||
|
||||
const isSteamCMDRunning = runningSteamServers.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleWebSocketMessage = (message: WebSocketMessage) => {
|
||||
if (message.type === 'step') {
|
||||
const data = message.data as StepData;
|
||||
|
||||
if (data.step === 'steam_download') {
|
||||
setRunningSteamServers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
|
||||
if (data.status === 'in_progress') {
|
||||
newSet.add(message.server_id);
|
||||
} else if (data.status === 'completed' || data.status === 'failed') {
|
||||
newSet.delete(message.server_id);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addMessageHandler(handleWebSocketMessage);
|
||||
return () => {
|
||||
removeMessageHandler(handleWebSocketMessage);
|
||||
};
|
||||
}, [addMessageHandler, removeMessageHandler]);
|
||||
|
||||
const dissociateServer = useCallback((serverId: string) => {
|
||||
setRunningSteamServers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(serverId);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SteamCMDContext.Provider
|
||||
value={{
|
||||
isSteamCMDRunning,
|
||||
runningSteamServers,
|
||||
dissociateServer
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SteamCMDContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
130
src/lib/schemas/config.ts
Normal file
130
src/lib/schemas/config.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export enum ConfigFile {
|
||||
configuration = 'configuration.json',
|
||||
assistRules = 'assistRules.json',
|
||||
event = 'event.json',
|
||||
eventRules = 'eventRules.json',
|
||||
settings = 'settings.json'
|
||||
}
|
||||
export const configFileSchema = z.enum(ConfigFile);
|
||||
|
||||
export enum ServerTab {
|
||||
statistics = 'statistics',
|
||||
configuration = 'configuration',
|
||||
assistRules = 'assistRules',
|
||||
event = 'event',
|
||||
eventRules = 'eventRules',
|
||||
settings = 'settings'
|
||||
}
|
||||
export const serverTabSchema = z.enum(ServerTab);
|
||||
|
||||
export const configurationSchema = z.object({
|
||||
udpPort: z.number().min(1025).max(65535),
|
||||
tcpPort: z.number().min(1025).max(65535),
|
||||
maxConnections: z.number().min(1).max(64),
|
||||
lanDiscovery: z.number().min(0).max(1),
|
||||
registerToLobby: z.number().min(0).max(2),
|
||||
configVersion: z.number().min(1).max(2).default(1).optional()
|
||||
});
|
||||
|
||||
export type Configuration = z.infer<typeof configurationSchema>;
|
||||
|
||||
export const assistRulesSchema = z.object({
|
||||
stabilityControlLevelMax: z.number().min(0).max(100).default(100),
|
||||
disableAutosteer: z.number().min(0).max(1).default(0),
|
||||
disableAutoLights: z.number().min(0).max(1).default(0),
|
||||
disableAutoWiper: z.number().min(0).max(1).default(0),
|
||||
disableAutoEngineStart: z.number().min(0).max(1).default(0),
|
||||
disableAutoPitLimiter: z.number().min(0).max(1).default(0),
|
||||
disableAutoGear: z.number().min(0).max(1).default(0),
|
||||
disableAutoClutch: z.number().min(0).max(1).default(0),
|
||||
disableIdealLine: z.number().min(0).max(1).default(0)
|
||||
});
|
||||
|
||||
export type AssistRules = z.infer<typeof assistRulesSchema>;
|
||||
|
||||
export const serverSettingsSchema = z.object({
|
||||
serverName: z.string().min(3).max(150),
|
||||
adminPassword: z.string().min(6).max(50),
|
||||
carGroup: z.string().min(1).max(50),
|
||||
trackMedalsRequirement: z.number().min(-1).max(3),
|
||||
safetyRatingRequirement: z.number().min(-1).max(99),
|
||||
racecraftRatingRequirement: z.number().min(-1).max(99),
|
||||
password: z.string().max(50).optional().or(z.literal('')),
|
||||
spectatorPassword: z.string().max(50).optional().or(z.literal('')),
|
||||
maxCarSlots: z.number().min(1).max(30),
|
||||
dumpLeaderboards: z.number().min(0).max(1).default(0),
|
||||
isRaceLocked: z.number().min(0).max(1).default(0),
|
||||
randomizeTrackWhenEmpty: z.number().min(0).max(1).default(0),
|
||||
centralEntryListPath: z.string().max(255).optional().or(z.literal('')),
|
||||
allowAutoDQ: z.number().min(0).max(1).default(0),
|
||||
shortFormationLap: z.number().min(0).max(1).default(0),
|
||||
formationLapType: z.number().min(0).max(3).default(0),
|
||||
ignorePrematureDisconnects: z.number().min(0).max(1).default(0)
|
||||
});
|
||||
|
||||
export type ServerSettings = z.infer<typeof serverSettingsSchema>;
|
||||
|
||||
export const sessionSchema = z.object({
|
||||
hourOfDay: z.number().min(1).max(24).default(14),
|
||||
dayOfWeekend: z.number().min(1).max(3).default(1),
|
||||
timeMultiplier: z.number().min(1).max(120).default(1),
|
||||
sessionType: z.string().min(1).max(20),
|
||||
sessionDurationMinutes: z.number().min(1).max(180).default(20)
|
||||
});
|
||||
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
export const eventConfigSchema = z.object({
|
||||
track: z.string().min(1).max(100),
|
||||
preRaceWaitingTimeSeconds: z.number().min(0).max(600).default(30),
|
||||
sessionOverTimeSeconds: z.number().min(0).max(300).default(30),
|
||||
ambientTemp: z.number().min(0).max(50).default(24),
|
||||
cloudLevel: z.number().min(0).max(1).default(0),
|
||||
rain: z.number().min(0).max(1).default(0),
|
||||
weatherRandomness: z.number().min(0).max(7).default(0),
|
||||
postQualySeconds: z.number().min(0).max(600).default(30),
|
||||
postRaceSeconds: z.number().min(0).max(600).default(30),
|
||||
simracerWeatherConditions: z.number().min(0).max(1).default(0),
|
||||
isFixedConditionQualification: z.number().min(0).max(1).default(0),
|
||||
sessions: z.array(sessionSchema).min(1).max(10)
|
||||
});
|
||||
|
||||
export type EventConfig = z.infer<typeof eventConfigSchema>;
|
||||
|
||||
export const eventRulesSchema = z.object({
|
||||
qualifyStandingType: z.number().min(-1).max(1).default(0),
|
||||
pitWindowLengthSec: z.number().min(-1).max(3600).default(30),
|
||||
driverStintTimeSec: z.number().min(-1).max(7200).default(30),
|
||||
mandatoryPitstopCount: z.number().min(-1).max(5).default(0),
|
||||
maxTotalDrivingTime: z.number().min(-1).max(14400).default(60),
|
||||
isRefuellingAllowedInRace: z.number().min(0).max(1).default(0),
|
||||
isRefuellingTimeFixed: z.number().min(0).max(1).default(0),
|
||||
isMandatoryPitstopRefuellingRequired: z.number().min(0).max(1).default(0),
|
||||
isMandatoryPitstopTyreChangeRequired: z.number().min(0).max(1).default(0),
|
||||
isMandatoryPitstopSwapDriverRequired: z.number().min(0).max(1).default(0),
|
||||
tyreSetCount: z.number().min(0).max(50).default(0)
|
||||
});
|
||||
|
||||
export type EventRules = z.infer<typeof eventRulesSchema>;
|
||||
|
||||
export const configurationsSchema = z.object({
|
||||
configuration: configurationSchema,
|
||||
assistRules: assistRulesSchema,
|
||||
settings: serverSettingsSchema,
|
||||
event: eventConfigSchema,
|
||||
eventRules: eventRulesSchema
|
||||
});
|
||||
|
||||
export type Configurations = z.infer<typeof configurationsSchema>;
|
||||
|
||||
export const configSchemaMap = {
|
||||
[ConfigFile.configuration]: configurationSchema,
|
||||
[ConfigFile.assistRules]: assistRulesSchema,
|
||||
[ConfigFile.event]: eventConfigSchema,
|
||||
[ConfigFile.eventRules]: eventRulesSchema,
|
||||
[ConfigFile.settings]: serverSettingsSchema
|
||||
};
|
||||
|
||||
export type Config = Configuration | AssistRules | EventConfig | EventRules | ServerSettings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user