5
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM node:22
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
USER node
|
18
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"dockerComposeFile": "docker-compose.yml",
|
||||||
|
"service": "app",
|
||||||
|
"workspaceFolder": "/workspace",
|
||||||
|
"forwardPorts": ["3306:3306", "4321:4321"],
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postCreateCommand": "npm install",
|
||||||
|
"customizations": {
|
||||||
|
"jetbrains": {
|
||||||
|
"plugins": ["org.jetbrains.plugins.astro", "dev.blachut.svelte.lang"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
.devcontainer/docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: .devcontainer/Dockerfile
|
||||||
|
command: sleep infinity
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql://website:website@db:3306/website
|
||||||
|
ports:
|
||||||
|
- '4321:4321'
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:latest
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: root
|
||||||
|
MARIADB_DATABASE: website
|
||||||
|
MARIADB_USER: website
|
||||||
|
MARIADB_PASSWORD: website
|
||||||
|
ports:
|
||||||
|
- '3306:3306'
|
||||||
|
restart: always
|
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
START_DATE=2025-06-23T00:19:00+0200
|
||||||
|
|
||||||
|
DATABASE_URI=mysql://website:website@localhost:3306/website
|
||||||
|
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
TEAMSPEAK_LINK=http://example.com
|
||||||
|
DISCORD_LINK=http://example.com
|
||||||
|
PAYPAL_LINK=http://example.com
|
||||||
|
SERVER_IP=1.1.1.1
|
||||||
|
|
||||||
|
BASE_PATH=http://localhost:4321
|
34
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm i
|
||||||
|
|
||||||
|
- name: Build website
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
HOST: ${{ secrets.SSH_HOST}}
|
||||||
|
USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/.ssh" && touch "$HOME/.ssh/known_hosts"
|
||||||
|
echo "$SSH_KEY" > "$HOME/.ssh/deploy_key"
|
||||||
|
chmod 700 "$HOME/.ssh" && chmod 600 "$HOME/.ssh/known_hosts" && chmod 600 "$HOME/.ssh/deploy_key"
|
||||||
|
eval $(ssh-agent)
|
||||||
|
ssh-add "$HOME/.ssh/deploy_key"
|
||||||
|
ssh-keyscan -t rsa "$HOST" >> "$HOME/.ssh/known_hosts"
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no $USER@$HOST "rm -r /opt/website; mkdir -p /opt/website"
|
||||||
|
scp -r -o StrictHostKeyChecking=no $(ls -d -1 dist/*) $(ls package*) $USER@$HOST:/opt/website
|
||||||
|
ssh -o StrictHostKeyChecking=no $USER@$HOST "cd /opt/website; npm i --omit=dev; systemctl restart website"
|
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
25
.prettierrc.mjs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import("prettier").Config} */
|
||||||
|
export default {
|
||||||
|
useTabs: false,
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'none',
|
||||||
|
tabWidth: 2,
|
||||||
|
printWidth: 120,
|
||||||
|
plugins: ['prettier-plugin-astro', 'prettier-plugin-svelte'],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: '*.svelte',
|
||||||
|
options: {
|
||||||
|
parser: 'svelte'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: '*.astro',
|
||||||
|
options: {
|
||||||
|
parser: 'astro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
188
README.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
## API
|
||||||
|
|
||||||
|
> Wenn die env variable `API_SECRET` gesetzt ist, muss jede API Request den HTTP Header `Authorization: Basic <API_SECRET>` haben.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST</code> <code>/api/feedback</code> (Erstellt Feedbackformulare)</summary>
|
||||||
|
|
||||||
|
##### Request Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
// Interner Event Name (oder ID)
|
||||||
|
"event": string,
|
||||||
|
// Event Titel, wird über dem Feedbackformular angezeigt
|
||||||
|
"title": string,
|
||||||
|
// UUIDs aller Spieler, die das Feedback ausfüllen sollen
|
||||||
|
"users": string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Response Codes
|
||||||
|
|
||||||
|
| http code | beschreibung |
|
||||||
|
| --------- | ------------------------------------------ |
|
||||||
|
| 200 | / |
|
||||||
|
| 400 | Der Request Body ist falsch |
|
||||||
|
| 401 | Es wurde ein falsches API Secret angegeben |
|
||||||
|
|
||||||
|
##### Response Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"feedback": {
|
||||||
|
// UUID eines Spieler
|
||||||
|
"uuid": string
|
||||||
|
// URL zum Feedbackformular
|
||||||
|
"url": string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST</code> <code>/api/player/death</code> (Registriert einen Spielertod)</summary>
|
||||||
|
|
||||||
|
##### Request Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
// UUID des getöteten Spielers
|
||||||
|
"user": string,
|
||||||
|
// UUID des Spielers, der den Kill gemacht hat
|
||||||
|
"killer": string | null,
|
||||||
|
// Todesnachricht
|
||||||
|
"message": string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Response Codes
|
||||||
|
|
||||||
|
| http code | beschreibung |
|
||||||
|
| --------- | -------------------------------------------------------------------------------- |
|
||||||
|
| 200 | / |
|
||||||
|
| 400 | Der Request Body ist falsch |
|
||||||
|
| 401 | Es wurde ein falsches API Secret angegeben |
|
||||||
|
| 404 | Der getötete Spieler, oder der Spieler der den Kill gemacht hat, existiert nicht |
|
||||||
|
|
||||||
|
##### Response Body
|
||||||
|
|
||||||
|
`/`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST</code> <code>/api/player/status</code> (Status eines Spielers)</summary>
|
||||||
|
|
||||||
|
##### Request Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
// UUID eines Spielers
|
||||||
|
"user": string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Response Codes
|
||||||
|
|
||||||
|
| http code | beschreibung |
|
||||||
|
| --------- | --------------------------------------------------- |
|
||||||
|
| 200 | / |
|
||||||
|
| 400 | Der Request Body ist falsch |
|
||||||
|
| 401 | Es wurde ein falsches API Secret angegeben |
|
||||||
|
| 404 | Der Spieler existiert nicht oder ist in keinem Team |
|
||||||
|
|
||||||
|
##### Response Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"team": {
|
||||||
|
// Teamname
|
||||||
|
"name": string,
|
||||||
|
// Teamfarbe in HEX
|
||||||
|
"color": string
|
||||||
|
},
|
||||||
|
// Ob der Spieler Tot ist
|
||||||
|
"dead": boolean,
|
||||||
|
// Gewichtung aller Strikes
|
||||||
|
"strikeWeight": number,
|
||||||
|
// UTC timestamp wann das Team zuletzt gejoined ist
|
||||||
|
"lastJoined": number | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>PUT</code> <code>/api/player/status</code> (Updatet den Status eines Spielers)</summary>
|
||||||
|
|
||||||
|
##### Request Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
// UUID eines Spieler
|
||||||
|
"user": string,
|
||||||
|
// Registriert einen neuen UTC timestamp an dem das Team zuletzt gejoined ist
|
||||||
|
"lastJoined": number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Response Codes
|
||||||
|
|
||||||
|
| http code | beschreibung |
|
||||||
|
| --------- | ------------------------------------------ |
|
||||||
|
| 200 | / |
|
||||||
|
| 400 | Der Request Body ist falsch |
|
||||||
|
| 401 | Es wurde ein falsches API Secret angegeben |
|
||||||
|
|
||||||
|
##### Response Body
|
||||||
|
|
||||||
|
`/`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST</code> <code>/api/teams</code> (Liste aller Teams)</summary>
|
||||||
|
|
||||||
|
##### Request Body
|
||||||
|
|
||||||
|
`/`
|
||||||
|
|
||||||
|
##### Response Codes
|
||||||
|
|
||||||
|
| http code | beschreibung |
|
||||||
|
| --------- | ------------------------------------------ |
|
||||||
|
| 200 | / |
|
||||||
|
| 400 | Der Request Body ist falsch |
|
||||||
|
| 401 | Es wurde ein falsches API Secret angegeben |
|
||||||
|
|
||||||
|
##### Response Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
// Teamname
|
||||||
|
"name": string,
|
||||||
|
// Teamfarbe in HEX
|
||||||
|
"color": string,
|
||||||
|
// UUIDs aller Teammitglieder
|
||||||
|
"users": (string | null)[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Webhook
|
||||||
|
|
||||||
|
> Die env variable `WEBHOOK_ENDPOINT` muss gesetzt und eine valide HTTP URL sein.
|
||||||
|
|
||||||
|
Bei bestimmten Aktionen wird an den Webhook Endpoint ein Webhook gesendet.
|
||||||
|
Die Art des Webhooks wird dabei durch den `x-webhook-action` HTTP Header angegeben und hat einen festgelegten JSON Body.
|
||||||
|
|
||||||
|
Das Webhook wir so oft gesendet, bis der angegebene Webhook Endpoint eine Response mit Status `200` zurücksendet.
|
||||||
|
|
||||||
|
Alle Webhooks:
|
||||||
|
|
||||||
|
| Beschreibung | HTTP Header | Body |
|
||||||
|
| -------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Ein Team hat ein Strike bekommen | <pre>x-webhook-action: strike</pre> | <pre>{<br> // UUIDs aller Nutzer des Teams, das einen Strike bekommen hat<br> "users": string[],<br> // Gewichtung aller Strikes, die das Team insgesamt bekommen hat<br> "totalWeight": number<br>}</pre> |
|
53
astro.config.mjs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig, envField } from 'astro/config';
|
||||||
|
|
||||||
|
import icon from 'astro-icon';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
import svelte, { vitePreprocess } from '@astrojs/svelte';
|
||||||
|
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
import inoxToolsRuntimeLogger from '@inox-tools/runtime-logger';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
|
||||||
|
prefetch: true,
|
||||||
|
|
||||||
|
devToolbar: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()]
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [icon(), svelte({ preprocess: vitePreprocess() }), inoxToolsRuntimeLogger()],
|
||||||
|
|
||||||
|
env: {
|
||||||
|
schema: {
|
||||||
|
API_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
|
||||||
|
ADMIN_USER: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
|
||||||
|
START_DATE: envField.string({ context: 'client', access: 'public' }),
|
||||||
|
|
||||||
|
WEBHOOK_ENDPOINT: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||||
|
|
||||||
|
TEAMSPEAK_LINK: envField.string({ context: 'client', access: 'public' }),
|
||||||
|
DISCORD_LINK: envField.string({ context: 'client', access: 'public' }),
|
||||||
|
PAYPAL_LINK: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||||
|
SERVER_IP: envField.string({ context: 'client', access: 'public' }),
|
||||||
|
|
||||||
|
DATABASE_URI: envField.string({ context: 'server', access: 'secret' }),
|
||||||
|
BASE_PATH: envField.string({ context: 'client', access: 'public' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
})
|
||||||
|
});
|
49
eslint.config.mjs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import astro from 'eslint-plugin-astro';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...astro.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
destructuredArrayIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
globalIgnores(['.astro/*', '.devcontainer/*', 'dist/*'])
|
||||||
|
]);
|
10660
package-lock.json
generated
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev --host",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview --host",
|
||||||
|
"astro": "astro",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.2.1",
|
||||||
|
"@astrojs/svelte": "^7.0.13",
|
||||||
|
"@iconify-json/fa-brands": "^1.2.1",
|
||||||
|
"@iconify-json/heroicons": "^1.2.2",
|
||||||
|
"@iconify/svelte": "^4.2.0",
|
||||||
|
"@inox-tools/runtime-logger": "^0.4.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"astro": "^5.7.10",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"daisyui": "^5.0.25",
|
||||||
|
"drizzle-orm": "^0.41.0",
|
||||||
|
"mysql2": "^3.14.0",
|
||||||
|
"nanostores": "^1.0.1",
|
||||||
|
"sass-embedded": "^1.87.0",
|
||||||
|
"skinview3d": "^3.3.0",
|
||||||
|
"tailwindcss": "^4.1.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@typescript-eslint/parser": "^8.32.1",
|
||||||
|
"eslint": "^9.26.0",
|
||||||
|
"eslint-plugin-astro": "^1.3.1",
|
||||||
|
"eslint-plugin-svelte": "^3.7.0",
|
||||||
|
"globals": "^16.1.0",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"prettier-plugin-astro": "0.14.1",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"svelte": "^5.30.1",
|
||||||
|
"typescript-eslint": "^8.32.1"
|
||||||
|
}
|
||||||
|
}
|
9
public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 749 B |
45
src/actions/admin.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { defineAction } from 'astro:actions';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
|
||||||
|
export const admin = {
|
||||||
|
addAdmin: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
permissions: z.number()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||||
|
const { id } = await db.addAdmin(input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editAdmin: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
permissions: z.number()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||||
|
|
||||||
|
await db.editAdmin(input);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
admins: defineAction({
|
||||||
|
handler: async (_, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
admins: await db.getAdmins({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
40
src/actions/feedback.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { defineAction } from 'astro:actions';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
|
||||||
|
export const feedback = {
|
||||||
|
addWebsiteFeedback: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
content: z.string()
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
await db.addFeedback({
|
||||||
|
event: 'website-feedback',
|
||||||
|
content: input.content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
addWebsiteContact: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
content: z.string(),
|
||||||
|
email: z.string().email()
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
await db.addFeedback({
|
||||||
|
event: 'website-contact',
|
||||||
|
content: `${input.content}\n\nEmail: ${input.email}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
feedbacks: defineAction({
|
||||||
|
handler: async (_, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Feedback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
feedbacks: await db.getFeedbacks({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
19
src/actions/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { session } from './session.ts';
|
||||||
|
import { signup } from './signup.ts';
|
||||||
|
import { user } from './user.ts';
|
||||||
|
import { admin } from './admin.ts';
|
||||||
|
import { team } from './team.ts';
|
||||||
|
import { settings } from './settings.ts';
|
||||||
|
import { feedback } from './feedback.ts';
|
||||||
|
import { report } from './report.ts';
|
||||||
|
|
||||||
|
export const server = {
|
||||||
|
admin,
|
||||||
|
session,
|
||||||
|
signup,
|
||||||
|
team,
|
||||||
|
user,
|
||||||
|
report,
|
||||||
|
feedback,
|
||||||
|
settings
|
||||||
|
};
|
80
src/actions/report.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { defineAction } from 'astro:actions';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
|
||||||
|
export const report = {
|
||||||
|
addReport: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
reason: z.string(),
|
||||||
|
body: z.string().nullable(),
|
||||||
|
createdAt: z.string().datetime().nullable(),
|
||||||
|
reporter: z.number(),
|
||||||
|
reported: z.number().nullable()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||||
|
|
||||||
|
const { id } = await db.addReport({
|
||||||
|
reason: input.reason,
|
||||||
|
body: input.body,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
reporterTeamId: input.reporter,
|
||||||
|
reportedTeamId: input.reported
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
reportStatus: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
reportId: z.number()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportStatus: await db.getReportStatus(input)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editReportStatus: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
reportId: z.number(),
|
||||||
|
status: z.enum(['open', 'closed']).nullable(),
|
||||||
|
notice: z.string().nullable(),
|
||||||
|
statement: z.string().nullable(),
|
||||||
|
strikeId: z.number().nullable()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||||
|
|
||||||
|
await db.editReportStatus(input);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
reports: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
reporter: z.string().nullish(),
|
||||||
|
reported: z.string().nullish()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reports: await db.getReports(input)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
strikeReasons: defineAction({
|
||||||
|
handler: async (_, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||||
|
|
||||||
|
return {
|
||||||
|
strikeReasons: await db.getStrikeReasons({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
49
src/actions/session.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ActionError, defineAction } from 'astro:actions';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { ADMIN_USER, ADMIN_PASSWORD } from 'astro:env/server';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
|
||||||
|
export const session = {
|
||||||
|
login: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
let admin;
|
||||||
|
if (input.username === ADMIN_USER && input.password === ADMIN_PASSWORD) {
|
||||||
|
admin = {
|
||||||
|
id: -1,
|
||||||
|
username: ADMIN_USER,
|
||||||
|
permissions: new Permissions(Permissions.allPermissions())
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
admin = await db.existsAdmin(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Nutzername und Passwort stimmen nicht überein'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Session.newSession(admin.id, admin.permissions, context.cookies);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
permissions: admin.permissions.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
logout: defineAction({
|
||||||
|
handler: async (_, context) => {
|
||||||
|
const session = Session.actionSessionFromCookies(context.cookies);
|
||||||
|
|
||||||
|
session.invalidate(context.cookies);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
23
src/actions/settings.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineAction } from 'astro:actions';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
setSettings: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
settings: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string().nullable()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Settings);
|
||||||
|
|
||||||
|
await db.setSettings(input);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
119
src/actions/signup.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { ActionError, defineAction } from 'astro:actions';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { getJavaUuid } from '@util/minecraft.ts';
|
||||||
|
import { getSetting, SettingKey } from '@util/settings.ts';
|
||||||
|
|
||||||
|
export const signup = {
|
||||||
|
signup: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
firstname: z.string().min(2),
|
||||||
|
lastname: z.string().min(2),
|
||||||
|
// this will be inaccurate as it is evaluated only once
|
||||||
|
birthday: z
|
||||||
|
.string()
|
||||||
|
.date()
|
||||||
|
.max(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
|
||||||
|
phone: z.string().nullable(),
|
||||||
|
username: z.string(),
|
||||||
|
|
||||||
|
teamMember: z.string(),
|
||||||
|
teamName: z.string().nullable()
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
// check if signup is allowed
|
||||||
|
if (!(await getSetting(db, SettingKey.SignupEnabled))) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Die Anmeldung ist derzeit deaktiviert'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if username and team member is equal
|
||||||
|
if (input.username.toLowerCase() === input.teamMember.toLowerCase()) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Du kannst nicht mit dir selber in einem Team sein'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the user were already signed up
|
||||||
|
if (await db.getUserByUsername({ username: input.username })) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Du hast dich bereits registriert'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMember = await db.getUserByUsername({ username: input.teamMember });
|
||||||
|
const teamDraft = await db.getTeamDraftByMemberOne({ memberOneName: input.teamMember });
|
||||||
|
|
||||||
|
// check if the team member already signed up but is in another team already
|
||||||
|
if (teamMember && (!teamDraft || teamDraft.memberTwoName.toLowerCase() != input.username.toLowerCase())) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Dein Teampartner ist bereits in einem anderen Team'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let uuid;
|
||||||
|
try {
|
||||||
|
uuid = await getJavaUuid(input.username);
|
||||||
|
} catch (_) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `Es wurde kein Minecraft Java Account mit dem Username ${input.username} gefunden`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamDraft) {
|
||||||
|
// check if a team with the same name already exists
|
||||||
|
if (input.teamName) {
|
||||||
|
if (await db.getTeamByName({ name: input.teamName })) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Es gibt bereits ein Team mit diesem Namen'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// no team draft is present and `input.teamName` is not present
|
||||||
|
} else {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Es ist noch kein Team auf dich und deinen Teampartner registriert'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await db.transaction(async (tx) => {
|
||||||
|
const user = await tx.addUser({
|
||||||
|
firstname: input.firstname,
|
||||||
|
lastname: input.lastname,
|
||||||
|
birthday: input.birthday,
|
||||||
|
telephone: input.phone,
|
||||||
|
username: input.username,
|
||||||
|
uuid: uuid
|
||||||
|
});
|
||||||
|
|
||||||
|
let team;
|
||||||
|
if (teamDraft) {
|
||||||
|
team = await tx.getTeamById({ id: teamDraft.teamId });
|
||||||
|
} else {
|
||||||
|
team = await tx.addTeam({ name: input.teamName! });
|
||||||
|
|
||||||
|
await tx.addTeamDraft({ memberOneName: input.username, memberTwoName: input.teamMember, teamId: team.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.addTeamMember({ teamId: team.id, userId: user.id });
|
||||||
|
|
||||||
|
return team;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
team: {
|
||||||
|
name: team.name,
|
||||||
|
color: team.color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
122
src/actions/team.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { defineAction } from 'astro:actions';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
|
||||||
|
export const team = {
|
||||||
|
addTeam: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
lastJoined: z.string().datetime().nullable(),
|
||||||
|
memberOne: z.object({
|
||||||
|
id: z.number().nullish(),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
memberTwo: z.object({
|
||||||
|
id: z.number().nullish(),
|
||||||
|
username: z.string()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
const teamId = await db.transaction(async (tx) => {
|
||||||
|
const team = await tx.addTeam({
|
||||||
|
name: input.name,
|
||||||
|
color: input.color
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.addTeamDraft({
|
||||||
|
memberOneName: input.memberOne.username,
|
||||||
|
memberTwoName: input.memberTwo.username,
|
||||||
|
teamId: team.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.memberOne.id) {
|
||||||
|
await tx.addTeamMember({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: input.memberOne.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.memberTwo.id) {
|
||||||
|
await tx.addTeamMember({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: input.memberTwo.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return team.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign(input, { id: teamId });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editTeam: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
lastJoined: z.string().datetime().nullable(),
|
||||||
|
memberOne: z.object({
|
||||||
|
id: z.number().nullable(),
|
||||||
|
username: z.string()
|
||||||
|
}),
|
||||||
|
memberTwo: z.object({
|
||||||
|
id: z.number().nullable(),
|
||||||
|
username: z.string()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.editTeam({
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
color: input.color,
|
||||||
|
lastJoined: input.lastJoined
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.deleteTeamDraft({ teamId: input.id });
|
||||||
|
await tx.deleteTeamMemberByTeamId({ teamId: input.id });
|
||||||
|
|
||||||
|
await tx.addTeamDraft({
|
||||||
|
memberOneName: input.memberOne.username,
|
||||||
|
memberTwoName: input.memberTwo.username,
|
||||||
|
teamId: input.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.memberOne.id) {
|
||||||
|
await tx.addTeamMember({
|
||||||
|
teamId: input.id,
|
||||||
|
userId: input.memberOne.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.memberTwo.id) {
|
||||||
|
await tx.addTeamMember({
|
||||||
|
teamId: input.id,
|
||||||
|
userId: input.memberTwo.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
teams: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
name: z.string().nullish(),
|
||||||
|
username: z.string().nullish(),
|
||||||
|
limit: z.number().optional()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams: await db.getTeams(input)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
88
src/actions/user.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { ActionError, defineAction } from 'astro:actions';
|
||||||
|
import { z } from 'astro:schema';
|
||||||
|
import { db } from '@db/database.ts';
|
||||||
|
import { Session } from '@util/session.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
|
||||||
|
export const user = {
|
||||||
|
addUser: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
firstname: z.string(),
|
||||||
|
lastname: z.string(),
|
||||||
|
birthday: z.string().date(),
|
||||||
|
telephone: z.string().nullable(),
|
||||||
|
username: z.string(),
|
||||||
|
uuid: z.string().nullable()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
if (await db.existsUser({ username: input.username })) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Der Benutzername ist bereits registriert'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await db.addUser({
|
||||||
|
firstname: input.firstname,
|
||||||
|
lastname: input.lastname,
|
||||||
|
birthday: input.birthday,
|
||||||
|
telephone: input.telephone,
|
||||||
|
username: input.username,
|
||||||
|
uuid: input.uuid
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editUser: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
firstname: z.string(),
|
||||||
|
lastname: z.string(),
|
||||||
|
birthday: z.string().date(),
|
||||||
|
telephone: z.string().nullable(),
|
||||||
|
username: z.string(),
|
||||||
|
uuid: z.string().nullable()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
const user = await db.existsUser({ username: input.username });
|
||||||
|
if (user && user.id !== input.id) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Ein Spieler mit dem Benutzernamen existiert bereits'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.editUser({
|
||||||
|
id: input.id,
|
||||||
|
firstname: input.firstname,
|
||||||
|
lastname: input.lastname,
|
||||||
|
birthday: input.birthday,
|
||||||
|
telephone: input.telephone,
|
||||||
|
username: input.username,
|
||||||
|
uuid: input.uuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
users: defineAction({
|
||||||
|
input: z.object({
|
||||||
|
username: z.string().nullish(),
|
||||||
|
limit: z.number().optional()
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||||
|
|
||||||
|
const users = await db.getUsers(input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: users
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
63
src/app/admin/admins/Admins.svelte
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Badges from './Badges.svelte';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import type { Admin } from './types.ts';
|
||||||
|
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
||||||
|
import { admins } from './state.ts';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { editAdmin } from './actions.ts';
|
||||||
|
|
||||||
|
// consts
|
||||||
|
const availablePermissionBadges = {
|
||||||
|
[Permissions.Admin.value]: 'Admin',
|
||||||
|
[Permissions.Users.value]: 'Users',
|
||||||
|
[Permissions.Reports.value]: 'Reports',
|
||||||
|
[Permissions.Feedback.value]: 'Feedback',
|
||||||
|
[Permissions.Settings.value]: 'Settings',
|
||||||
|
[Permissions.Tools.value]: 'Tools'
|
||||||
|
};
|
||||||
|
|
||||||
|
// states
|
||||||
|
let editAdminPopupAdmin = $state<Admin | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 5%">#</th>
|
||||||
|
<th style="width: 30%">Benutzername</th>
|
||||||
|
<th style="width: 60%">Berechtigungen</th>
|
||||||
|
<th style="width: 5%"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $admins as admin, i (admin.id)}
|
||||||
|
<tr class="hover:bg-base-200">
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td>{admin.username}</td>
|
||||||
|
<td>
|
||||||
|
<Badges available={availablePermissionBadges} set={new Permissions(admin.permissions).toNumberArray()} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="cursor-pointer" onclick={() => (editAdminPopupAdmin = admin)}>
|
||||||
|
<Icon icon="heroicons:pencil-square" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key editAdminPopupAdmin}
|
||||||
|
<CreateOrEditPopup
|
||||||
|
popupTitle="Admin bearbeiten"
|
||||||
|
submitButtonTitle="Admin bearbeiten"
|
||||||
|
confirmPopupTitle="Admin bearbeiten"
|
||||||
|
confirmPopupMessage="Bist du sicher, dass du den Admin bearbeiten möchtest?"
|
||||||
|
admin={editAdminPopupAdmin}
|
||||||
|
open={editAdminPopupAdmin != null}
|
||||||
|
onSubmit={editAdmin}
|
||||||
|
/>
|
||||||
|
{/key}
|
49
src/app/admin/admins/Badges.svelte
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
available: { [k: number]: string };
|
||||||
|
set: number[];
|
||||||
|
onUpdate?: (set: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { available, set, onUpdate }: Props = $props();
|
||||||
|
let reactiveSet = $state(set);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onOptionSelect(e: Event) {
|
||||||
|
const value = Number((e.target as HTMLSelectElement).value);
|
||||||
|
reactiveSet.push(value);
|
||||||
|
|
||||||
|
onUpdate?.(reactiveSet);
|
||||||
|
|
||||||
|
(e.target as HTMLSelectElement).value = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBadgeRemove(badge: number) {
|
||||||
|
const index = reactiveSet.indexOf(badge);
|
||||||
|
if (index !== -1) {
|
||||||
|
reactiveSet.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if onUpdate}
|
||||||
|
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||||
|
<option selected hidden>-</option>
|
||||||
|
{#each Object.entries(available) as [value, badge] (value)}
|
||||||
|
<option {value} hidden={reactiveSet.indexOf(Number(value)) !== -1}>{badge}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flow flex-wrap gap-2">
|
||||||
|
{#each reactiveSet as badge (badge)}
|
||||||
|
<div class="badge badge-outline gap-1">
|
||||||
|
{#if onUpdate}
|
||||||
|
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(badge)}>✕</button>
|
||||||
|
{/if}
|
||||||
|
<span>{available[badge]}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
115
src/app/admin/admins/CreateOrEditPopup.svelte
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import Badges from './Badges.svelte';
|
||||||
|
import type { Admin } from './types.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
import Password from '@components/input/Password.svelte';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let modalForm: HTMLFormElement;
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
popupTitle: string;
|
||||||
|
submitButtonTitle: string;
|
||||||
|
confirmPopupTitle: string;
|
||||||
|
confirmPopupMessage: string;
|
||||||
|
|
||||||
|
admin: Admin | null;
|
||||||
|
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
onSubmit: (admin: Admin & { password: string }) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// consts
|
||||||
|
const availablePermissionBadges = {
|
||||||
|
[Permissions.Admin.value]: 'Admin',
|
||||||
|
[Permissions.Users.value]: 'Users',
|
||||||
|
[Permissions.Reports.value]: 'Reports',
|
||||||
|
[Permissions.Feedback.value]: 'Feedback',
|
||||||
|
[Permissions.Settings.value]: 'Settings',
|
||||||
|
[Permissions.Tools.value]: 'Tools'
|
||||||
|
};
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, admin, open, onSubmit, onClose }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let username = $state<string | null>(admin?.username ?? null);
|
||||||
|
let password = $state<string | null>(null);
|
||||||
|
let permissions = $state<number | null>(admin?.permissions ?? 0);
|
||||||
|
|
||||||
|
let submitEnabled = $derived(!!(username && password));
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
if (open) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onBadgesUpdate(newPermissions: number[]) {
|
||||||
|
permissions = new Permissions(newPermissions).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaveButtonClick() {
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: confirmPopupTitle,
|
||||||
|
message: confirmPopupMessage,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSubmit({
|
||||||
|
id: admin?.id ?? -1,
|
||||||
|
username: username!,
|
||||||
|
password: password!,
|
||||||
|
permissions: permissions!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
modalForm.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||||
|
<form method="dialog" class="modal-box w-min" bind:this={modalForm}>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
|
||||||
|
<div class="w-full gap-x-4 gap-y-2">
|
||||||
|
<div class="w-[20rem]">
|
||||||
|
<Input type="text" bind:value={username} label="Username" required />
|
||||||
|
<Password bind:value={password} label="Password" required />
|
||||||
|
</div>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Berechtigungen</legend>
|
||||||
|
{#key admin}
|
||||||
|
<Badges
|
||||||
|
available={availablePermissionBadges}
|
||||||
|
set={new Permissions(permissions).toNumberArray()}
|
||||||
|
onUpdate={onBadgesUpdate}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
class:disabled={!submitEnabled}
|
||||||
|
disabled={!submitEnabled}
|
||||||
|
onclick={onSaveButtonClick}>{submitButtonTitle}</button
|
||||||
|
>
|
||||||
|
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
34
src/app/admin/admins/SidebarActions.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { addAdmin, fetchAdmins } from './actions.ts';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import CreateOrEditPopup from '@app/admin/admins/CreateOrEditPopup.svelte';
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
onMount(() => {
|
||||||
|
fetchAdmins();
|
||||||
|
});
|
||||||
|
|
||||||
|
// states
|
||||||
|
let newTeamPopupOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
||||||
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
|
<span>Neuer Admin</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key newTeamPopupOpen}
|
||||||
|
<CreateOrEditPopup
|
||||||
|
popupTitle="Admin erstellen"
|
||||||
|
submitButtonTitle="Admin erstellen"
|
||||||
|
confirmPopupTitle="Admin erstellen"
|
||||||
|
confirmPopupMessage="Bist du sicher, dass du den Admin erstellen möchtest?"
|
||||||
|
admin={null}
|
||||||
|
open={newTeamPopupOpen}
|
||||||
|
onSubmit={addAdmin}
|
||||||
|
onClose={() => (newTeamPopupOpen = false)}
|
||||||
|
/>
|
||||||
|
{/key}
|
41
src/app/admin/admins/actions.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { Admin } from './types.ts';
|
||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { admins } from './state.ts';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
export async function fetchAdmins() {
|
||||||
|
const { data, error } = await actions.admin.admins();
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
admins.set(data.admins);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAdmin(admin: Admin & { password: string }) {
|
||||||
|
const { data, error } = await actions.admin.addAdmin(admin);
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
admins.update((old) => {
|
||||||
|
old.push(Object.assign(admin, { id: data.id }));
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editAdmin(admin: Admin & { password: string }) {
|
||||||
|
const { error } = await actions.admin.editAdmin(admin);
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
admins.update((old) => {
|
||||||
|
const index = old.findIndex((a) => a.id == admin.id);
|
||||||
|
old[index] = admin;
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
4
src/app/admin/admins/state.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import type { Admin } from './types.ts';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const admins = writable<Admin[]>([]);
|
4
src/app/admin/admins/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import type { ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
export type Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
|
||||||
|
export type Admin = Admins[0];
|
29
src/app/admin/feedback/BottomBar.svelte
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Feedback } from './types.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
feedback: Feedback | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { feedback }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-10"
|
||||||
|
hidden={feedback === null}
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (feedback = null)}>✕</button>
|
||||||
|
<div class="w-96">
|
||||||
|
<Input value={feedback?.event} label="Event" readonly />
|
||||||
|
<Input value={feedback?.title} label="Titel" readonly />
|
||||||
|
<Input value={feedback?.user?.username} label="Nutzer" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="divider divider-horizontal"></div>
|
||||||
|
<div class="w-full">
|
||||||
|
<Textarea value={feedback?.content} label="Inhalt" rows={9} readonly dynamicWidth />
|
||||||
|
</div>
|
||||||
|
</div>
|
53
src/app/admin/feedback/Feedback.svelte
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BottomBar from './BottomBar.svelte';
|
||||||
|
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||||
|
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||||
|
import { feedbacks } from './state.ts';
|
||||||
|
import { fetchFeedbacks } from './actions.ts';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Feedback } from './types.ts';
|
||||||
|
|
||||||
|
// consts
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
// states
|
||||||
|
let activeFeedback = $state<Feedback | null>(null);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
onMount(() => {
|
||||||
|
fetchFeedbacks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-[70vh] max-h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<SortableTr data={feedbacks}>
|
||||||
|
<SortableTh style="width: 5%">#</SortableTh>
|
||||||
|
<SortableTh style="width: 10%">Event</SortableTh>
|
||||||
|
<SortableTh style="width: 20%" key="user.username">Nutzer</SortableTh>
|
||||||
|
<SortableTh style="width: 20%" key="lastChanged">Datum</SortableTh>
|
||||||
|
<SortableTh style="width: 45%">Inhalt</SortableTh>
|
||||||
|
</SortableTr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $feedbacks as feedback, i (feedback.id)}
|
||||||
|
<tr class="hover:bg-base-200" onclick={() => (activeFeedback = feedback)}>
|
||||||
|
<td>{(i + 1)}</td>
|
||||||
|
<td>{feedback.event}</td>
|
||||||
|
<td>{feedback.user?.username}</td>
|
||||||
|
<td>{dateFormat.format(new Date(feedback.lastChanged))}</td>
|
||||||
|
<td>{feedback.content}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BottomBar feedback={activeFeedback} />
|
13
src/app/admin/feedback/actions.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { feedbacks } from './state.ts';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
export async function fetchFeedbacks(reporter?: string | null, reported?: string | null) {
|
||||||
|
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedbacks.set(data.feedbacks);
|
||||||
|
}
|
4
src/app/admin/feedback/state.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Feedbacks } from './types.ts';
|
||||||
|
|
||||||
|
export const feedbacks = writable<Feedbacks>([]);
|
4
src/app/admin/feedback/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import type { ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
|
||||||
|
export type Feedback = Feedbacks[0];
|
87
src/app/admin/reports/BottomBar.svelte
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Report, ReportStatus, StrikeReasons } from './types.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
|
import Select from '@components/input/Select.svelte';
|
||||||
|
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||||
|
import { editReportStatus, getReportStatus } from '@app/admin/reports/actions.ts';
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
strikeReasons: StrikeReasons;
|
||||||
|
report: Report | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { strikeReasons, report }: Props = $props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let status = $state<'open' | 'closed' | null>(null);
|
||||||
|
let notice = $state<string | null>(null);
|
||||||
|
let statement = $state<string | null>(null);
|
||||||
|
|
||||||
|
// consts
|
||||||
|
const strikeReasonValues = strikeReasons.reduce(
|
||||||
|
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
// lifetime
|
||||||
|
$effect(() => {
|
||||||
|
if (!report) return;
|
||||||
|
|
||||||
|
getReportStatus(report).then((reportStatus) => {
|
||||||
|
if (!reportStatus) return;
|
||||||
|
|
||||||
|
status = reportStatus.status;
|
||||||
|
notice = reportStatus.notice;
|
||||||
|
statement = reportStatus.statement;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onSaveButtonClick() {
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: 'Änderungen speichern?',
|
||||||
|
message: 'Sollen die Änderungen am Report gespeichert werden?',
|
||||||
|
onConfirm: async () =>
|
||||||
|
editReportStatus(report!, {
|
||||||
|
status: status,
|
||||||
|
notice: notice,
|
||||||
|
statement: statement,
|
||||||
|
strikeId: null
|
||||||
|
} as ReportStatus)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-2"
|
||||||
|
hidden={report === null}
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (report = null)}>✕</button>
|
||||||
|
<div class="w-[34rem]">
|
||||||
|
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
|
||||||
|
<TeamSearch value={report?.reported?.name} label="Reportetes Team" />
|
||||||
|
<Textarea bind:value={notice} label="Interne Notizen" rows={8} />
|
||||||
|
</div>
|
||||||
|
<div class="divider divider-horizontal"></div>
|
||||||
|
<div class="w-full">
|
||||||
|
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
|
||||||
|
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} />
|
||||||
|
</div>
|
||||||
|
<div class="divider divider-horizontal"></div>
|
||||||
|
<div class="flex flex-col w-[42rem]">
|
||||||
|
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} />
|
||||||
|
<Select
|
||||||
|
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
|
||||||
|
defaultValue="Unbearbeitet"
|
||||||
|
label="Bearbeitungsstatus"
|
||||||
|
dynamicWidth
|
||||||
|
/>
|
||||||
|
<Select bind:value={status} values={strikeReasonValues} defaultValue="" label="Vergehen" dynamicWidth></Select>
|
||||||
|
<div class="divider mt-0 mb-2"></div>
|
||||||
|
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
97
src/app/admin/reports/CreatePopup.svelte
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||||
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
|
import Checkbox from '@components/input/Checkbox.svelte';
|
||||||
|
import type { Report } from './types.ts';
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let modalForm: HTMLFormElement;
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
onSubmit: (report: Report) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// input
|
||||||
|
let { open, onSubmit, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// form
|
||||||
|
let reason = $state<string | null>(null);
|
||||||
|
let body = $state<string | null>(null);
|
||||||
|
let editable = $state<boolean>(true);
|
||||||
|
let reporter = $state<Report['reporter'] | null>(null);
|
||||||
|
let reported = $state<Report['reported'] | null>(null);
|
||||||
|
|
||||||
|
let submitEnabled = $derived(!!(reason && reporter));
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
if (open) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onSaveButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: 'Report erstellen',
|
||||||
|
message: 'Bist du sicher, dass du den Report erstellen möchtest?',
|
||||||
|
onConfirm: () => {
|
||||||
|
modalForm.submit();
|
||||||
|
onSubmit({
|
||||||
|
id: -1,
|
||||||
|
reason: reason!,
|
||||||
|
body: body!,
|
||||||
|
reporter: reporter!,
|
||||||
|
reported: reported!,
|
||||||
|
createdAt: editable ? null : new Date().toISOString(),
|
||||||
|
status: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
modalForm.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||||
|
<form method="dialog" class="modal-box" bind:this={modalForm}>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<h3 class="text-xt font-geist font-bold">Neuer Report</h3>
|
||||||
|
<div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<TeamSearch label="Report Team" required mustMatch onSubmit={(team) => (reporter = team)} />
|
||||||
|
<TeamSearch label="Reportetes Team" mustMatch onSubmit={(team) => (reported = team)} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1">
|
||||||
|
<Input label="Grund" bind:value={reason} required dynamicWidth />
|
||||||
|
<Textarea label="Inhalt" bind:value={body} rows={5} dynamicWidth />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 mt-2">
|
||||||
|
<Checkbox label="Report kann bearbeitet werden" bind:checked={editable} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
class:disabled={!submitEnabled}
|
||||||
|
disabled={!submitEnabled}
|
||||||
|
onclick={onSaveButtonClick}>Erstellen</button
|
||||||
|
>
|
||||||
|
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
50
src/app/admin/reports/Reports.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||||
|
import { reports } from './state.ts';
|
||||||
|
import type { Report, StrikeReasons } from './types.ts';
|
||||||
|
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||||
|
import BottomBar from '@app/admin/reports/BottomBar.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getStrikeReasons } from '@app/admin/reports/actions.ts';
|
||||||
|
|
||||||
|
// states
|
||||||
|
let strikeReasons = $state<StrikeReasons>([]);
|
||||||
|
let activeReport = $state<Report | null>(null);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
onMount(() => {
|
||||||
|
getStrikeReasons().then((data) => (strikeReasons = data ?? []));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<SortableTr data={reports}>
|
||||||
|
<SortableTh style="width: 5%">#</SortableTh>
|
||||||
|
<SortableTh>Grund</SortableTh>
|
||||||
|
<SortableTh>Report Team</SortableTh>
|
||||||
|
<SortableTh>Reportetes Team</SortableTh>
|
||||||
|
<SortableTh>Datum</SortableTh>
|
||||||
|
<SortableTh>Bearbeitungsstatus</SortableTh>
|
||||||
|
<SortableTh style="width: 5%"></SortableTh>
|
||||||
|
</SortableTr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $reports as report, i (report.id)}
|
||||||
|
<tr class="hover:bg-base-200" onclick={() => (activeReport = report)}>
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td>{report.reason}</td>
|
||||||
|
<td>{report.reporter.name}</td>
|
||||||
|
<td>{report.reported?.name}</td>
|
||||||
|
<td>{report.createdAt}</td>
|
||||||
|
<td>{report.status?.status}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key activeReport}
|
||||||
|
<BottomBar {strikeReasons} report={activeReport} />
|
||||||
|
{/key}
|
34
src/app/admin/reports/SidebarActions.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import { addReport, fetchReports } from '@app/admin/reports/actions.ts';
|
||||||
|
import CreatePopup from '@app/admin/reports/CreatePopup.svelte';
|
||||||
|
|
||||||
|
// states
|
||||||
|
let reporterUsernameFilter = $state<string | null>(null);
|
||||||
|
let reportedUsernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
|
let newReportPopupOpen = $state(false);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
fetchReports(reporterUsernameFilter, reportedUsernameFilter);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
|
||||||
|
<legend class="fieldset-legend">Filter</legend>
|
||||||
|
<Input bind:value={reporterUsernameFilter} label="Reporter Ersteller" />
|
||||||
|
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
|
||||||
|
</fieldset>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<button class="btn btn-soft w-full" onclick={() => (newReportPopupOpen = true)}>
|
||||||
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
|
<span>Neuer Report</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key newReportPopupOpen}
|
||||||
|
<CreatePopup open={newReportPopupOpen} onSubmit={addReport} onClose={() => (newReportPopupOpen = false)} />
|
||||||
|
{/key}
|
67
src/app/admin/reports/actions.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { reports } from './state.ts';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
import type { Report, ReportStatus } from './types.ts';
|
||||||
|
|
||||||
|
export async function fetchReports(reporterUsername: string | null, reportedUsername: string | null) {
|
||||||
|
const { data, error } = await actions.report.reports({ reporter: reporterUsername, reported: reportedUsername });
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.set(data.reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addReport(report: Report) {
|
||||||
|
const { data, error } = await actions.report.addReport({
|
||||||
|
reason: report.reason,
|
||||||
|
body: report.body,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
reporter: report.reporter.id,
|
||||||
|
reported: report.reported?.id ?? null
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.update((old) => {
|
||||||
|
old.push(Object.assign(report, { id: data.id, status: null }));
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportStatus(report: Report) {
|
||||||
|
const { data, error } = await actions.report.reportStatus({ reportId: report.id });
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.reportStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editReportStatus(report: Report, reportStatus: ReportStatus) {
|
||||||
|
const { error } = await actions.report.editReportStatus({
|
||||||
|
reportId: report.id,
|
||||||
|
status: reportStatus.status,
|
||||||
|
notice: reportStatus.notice,
|
||||||
|
statement: reportStatus.statement,
|
||||||
|
strikeId: reportStatus.strikeId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStrikeReasons() {
|
||||||
|
const { data, error } = await actions.report.strikeReasons();
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.strikeReasons;
|
||||||
|
}
|
4
src/app/admin/reports/state.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { Reports } from './types.ts';
|
||||||
|
|
||||||
|
export const reports = writable<Reports>([]);
|
14
src/app/admin/reports/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
export type Reports = Exclude<ActionReturnType<typeof actions.report.reports>['data'], undefined>['reports'];
|
||||||
|
export type Report = Reports[0];
|
||||||
|
|
||||||
|
export type ReportStatus = Exclude<
|
||||||
|
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
|
||||||
|
null
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type StrikeReasons = Exclude<
|
||||||
|
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||||
|
undefined
|
||||||
|
>['strikeReasons'];
|
137
src/app/admin/settings/Settings.svelte
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DynamicSettings } from './dynamicSettings.ts';
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
import { updateSettings } from './actions.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
settings: { name: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsInput = {
|
||||||
|
name: string;
|
||||||
|
entries: (
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
type: 'checkbox';
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
type: 'text';
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
type: 'textarea';
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
)[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
const { settings }: Props = $props();
|
||||||
|
|
||||||
|
const dynamicSettings = new DynamicSettings(
|
||||||
|
settings.reduce((prev, curr) => Object.assign(prev, { [curr.name]: curr.value }), {})
|
||||||
|
);
|
||||||
|
|
||||||
|
let changes = $state<{ [k: string]: string | null }>(dynamicSettings.getChanges());
|
||||||
|
|
||||||
|
// consts
|
||||||
|
const settingsInput: SettingsInput = [
|
||||||
|
{
|
||||||
|
name: 'Anmeldung',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Aktiviert',
|
||||||
|
type: 'checkbox',
|
||||||
|
value: dynamicSettings.signupEnabled(),
|
||||||
|
onChange: dynamicSettings.signupSetEnabled
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Text, wenn die Anmeldung deaktiviert ist',
|
||||||
|
type: 'textarea',
|
||||||
|
value: dynamicSettings.signupDisabledText(),
|
||||||
|
onChange: dynamicSettings.signupSetDisabledText
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Subtext, wenn die Anmeldung deaktiviert ist',
|
||||||
|
type: 'textarea',
|
||||||
|
value: dynamicSettings.signupDisabledSubtext(),
|
||||||
|
onChange: dynamicSettings.signupSetDisabledSubtext
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onSaveSettingsClick() {
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: 'Änderungen speichern?',
|
||||||
|
message: 'Sollen die Änderungen gespeichert werden?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
if (!(await updateSettings(changes))) return;
|
||||||
|
dynamicSettings.setChanges();
|
||||||
|
changes = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col items-center justify-between">
|
||||||
|
<div class="grid grid-cols-2 w-full">
|
||||||
|
{#each settingsInput as setting (setting.name)}
|
||||||
|
<div class="mx-12">
|
||||||
|
<div class="divider">{setting.name}</div>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
{#each setting.entries as entry (entry.name)}
|
||||||
|
<label class="flex justify-between">
|
||||||
|
<span class="mt-[.125rem] text-sm">{entry.name}</span>
|
||||||
|
{#if entry.type === 'checkbox'}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle"
|
||||||
|
onchange={(e) => {
|
||||||
|
entry.onChange(e.currentTarget.checked);
|
||||||
|
changes = dynamicSettings.getChanges();
|
||||||
|
}}
|
||||||
|
checked={entry.value}
|
||||||
|
/>
|
||||||
|
{:else if entry.type === 'text'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
onchange={(e) => {
|
||||||
|
entry.onChange(e.currentTarget.value);
|
||||||
|
changes = dynamicSettings.getChanges();
|
||||||
|
}}
|
||||||
|
value={entry.value}
|
||||||
|
/>
|
||||||
|
{:else if entry.type === 'textarea'}
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
value={entry.value}
|
||||||
|
onchange={(e) => {
|
||||||
|
entry.onChange(e.currentTarget.value);
|
||||||
|
changes = dynamicSettings.getChanges();
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-success mt-auto mb-8"
|
||||||
|
class:btn-disabled={Object.keys(changes).length === 0}
|
||||||
|
onclick={onSaveSettingsClick}>Speichern</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
19
src/app/admin/settings/actions.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
export async function updateSettings(changes: { [k: string]: string | null }) {
|
||||||
|
const { error } = await actions.settings.setSettings({
|
||||||
|
settings: Object.entries(changes).reduce(
|
||||||
|
(prev, curr) => {
|
||||||
|
prev.push({ name: curr[0], value: curr[1] });
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
[] as { name: string; value: string | null }[]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
44
src/app/admin/settings/dynamicSettings.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { SettingKey } from '@util/settings.ts';
|
||||||
|
|
||||||
|
export class DynamicSettings {
|
||||||
|
private settings: { [k: string]: string | null };
|
||||||
|
private changedSettings: { [k: string]: string | null } = {};
|
||||||
|
|
||||||
|
constructor(settings: typeof this.settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get<V extends string | boolean>(key: string, defaultValue: V): V {
|
||||||
|
const setting = this.changedSettings[key] ?? this.settings[key];
|
||||||
|
return setting != null ? JSON.parse(setting) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private set<V extends string | boolean>(key: string, value: V | null) {
|
||||||
|
if (this.settings[key] == value) {
|
||||||
|
delete this.changedSettings[key];
|
||||||
|
} else {
|
||||||
|
this.changedSettings[key] = value != null ? JSON.stringify(value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getChanges() {
|
||||||
|
return this.changedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChanges() {
|
||||||
|
this.settings = Object.assign(this.settings, this.changedSettings);
|
||||||
|
this.changedSettings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* signup enabled */
|
||||||
|
signupEnabled = () => this.get(SettingKey.SignupEnabled, false);
|
||||||
|
signupSetEnabled = (active: boolean) => this.set(SettingKey.SignupEnabled, active);
|
||||||
|
|
||||||
|
/* signup disabled text */
|
||||||
|
signupDisabledText = () => this.get(SettingKey.SignupDisabledMessage, '');
|
||||||
|
signupSetDisabledText = (text: string) => this.set(SettingKey.SignupDisabledMessage, text);
|
||||||
|
|
||||||
|
/* signup disabled subtext */
|
||||||
|
signupDisabledSubtext = () => this.get(SettingKey.SignupDisabledSubMessage, '');
|
||||||
|
signupSetDisabledSubtext = (text: string) => this.set(SettingKey.SignupDisabledSubMessage, text);
|
||||||
|
}
|
102
src/app/admin/teams/CreateOrEditPopup.svelte
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import type { Team } from '@app/admin/teams/types.ts';
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let modalForm: HTMLFormElement;
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
popupTitle: string;
|
||||||
|
submitButtonTitle: string;
|
||||||
|
confirmPopupTitle: string;
|
||||||
|
confirmPopupMessage: string;
|
||||||
|
|
||||||
|
team: Team | null;
|
||||||
|
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
onSubmit: (team: Team) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, team, open, onSubmit, onClose }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let name = $state<string | null>(team?.name ?? null);
|
||||||
|
let color = $state<string | null>(team?.color ?? null);
|
||||||
|
let lastJoined = $state<string | null>(team?.lastJoined ?? null);
|
||||||
|
let memberOne = $state<Team['memberOne']>(team?.memberOne ?? ({ username: null } as unknown as Team['memberOne']));
|
||||||
|
let memberTwo = $state<Team['memberOne']>(team?.memberTwo ?? ({ username: null } as unknown as Team['memberOne']));
|
||||||
|
|
||||||
|
let submitEnabled = $derived(!!(name && color && memberOne.username && memberTwo.username));
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
if (open) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onSaveButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: confirmPopupTitle,
|
||||||
|
message: confirmPopupMessage,
|
||||||
|
onConfirm: () => {
|
||||||
|
modalForm.submit();
|
||||||
|
onSubmit({
|
||||||
|
id: team?.id ?? -1,
|
||||||
|
name: name!,
|
||||||
|
color: color!,
|
||||||
|
lastJoined: lastJoined!,
|
||||||
|
memberOne: memberOne!,
|
||||||
|
memberTwo: memberTwo!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
modalForm.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||||
|
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
|
||||||
|
<div class="w-full flex flex-col">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Input type="color" label="Farbe" bind:value={color} />
|
||||||
|
<Input type="text" label="Name" bind:value={name} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<UserSearch label="Spieler 1" bind:value={memberOne.username} required mustMatch />
|
||||||
|
<UserSearch label="Spieler 2" bind:value={memberTwo.username} required />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Input type="date" label="Zuletzt gejoined" bind:value={lastJoined}></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
class:disabled={!submitEnabled}
|
||||||
|
disabled={!submitEnabled}
|
||||||
|
onclick={onSaveButtonClick}>{submitButtonTitle}</button
|
||||||
|
>
|
||||||
|
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
43
src/app/admin/teams/SidebarActions.svelte
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { addTeam, fetchTeams } from './actions.ts';
|
||||||
|
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
|
||||||
|
|
||||||
|
// states
|
||||||
|
let teamNameFilter = $state<string | null>(null);
|
||||||
|
let memberUsernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
|
let newTeamPopupOpen = $state(false);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
fetchTeams(teamNameFilter, memberUsernameFilter);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
|
||||||
|
<legend class="fieldset-legend">Filter</legend>
|
||||||
|
<Input bind:value={teamNameFilter} label="Team Name" />
|
||||||
|
<Input bind:value={memberUsernameFilter} label="Spieler Username" />
|
||||||
|
</fieldset>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
||||||
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
|
<span>Neues Team</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key newTeamPopupOpen}
|
||||||
|
<CreateOrEditPopup
|
||||||
|
popupTitle="Neues Team"
|
||||||
|
submitButtonTitle="Team erstellen"
|
||||||
|
confirmPopupTitle="Team erstellen"
|
||||||
|
confirmPopupMessage="Bist du sicher, dass du das Team erstellen möchtest?"
|
||||||
|
team={null}
|
||||||
|
open={newTeamPopupOpen}
|
||||||
|
onSubmit={addTeam}
|
||||||
|
onClose={() => (newTeamPopupOpen = false)}
|
||||||
|
/>
|
||||||
|
{/key}
|
65
src/app/admin/teams/Teams.svelte
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { teams } from './state.ts';
|
||||||
|
import type { Team } from './types.ts';
|
||||||
|
import { editTeam } from './actions.ts';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||||
|
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||||
|
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
|
||||||
|
|
||||||
|
// state
|
||||||
|
let editTeamPopupTeam = $state<Team | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<SortableTr data={teams}>
|
||||||
|
<SortableTh style="width: 5%">#</SortableTh>
|
||||||
|
<SortableTh style="width: 5%">Farbe</SortableTh>
|
||||||
|
<SortableTh style="width: 25%" key="name">Name</SortableTh>
|
||||||
|
<SortableTh style="width: 30%" key="memberOne.username">Spieler 1</SortableTh>
|
||||||
|
<SortableTh style="width: 30%" key="memberTwo.username">Spieler 2</SortableTh>
|
||||||
|
<SortableTh style="width: 5%"></SortableTh>
|
||||||
|
</SortableTr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $teams as team, i (team.id)}
|
||||||
|
<tr class="hover:bg-base-200">
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<div class="rounded-sm w-3 h-3" style="background-color: {team.color}"></div>
|
||||||
|
</td>
|
||||||
|
<td>{team.name}</td>
|
||||||
|
{#if team.memberOne.id != null}
|
||||||
|
<td>{team.memberOne.username}</td>
|
||||||
|
{:else}
|
||||||
|
<td class="text-base-content/30">{team.memberOne.username}</td>
|
||||||
|
{/if}
|
||||||
|
{#if team.memberTwo.id != null}
|
||||||
|
<td>{team.memberTwo.username}</td>
|
||||||
|
{:else}
|
||||||
|
<td class="text-base-content/30">{team.memberTwo.username}</td>
|
||||||
|
{/if}
|
||||||
|
<td>
|
||||||
|
<button class="cursor-pointer" onclick={() => (editTeamPopupTeam = team)}>
|
||||||
|
<Icon icon="heroicons:pencil-square" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key editTeamPopupTeam}
|
||||||
|
<CreateOrEditPopup
|
||||||
|
popupTitle="Team bearbeiten"
|
||||||
|
submitButtonTitle="Team bearbeiten"
|
||||||
|
confirmPopupTitle="Team bearbeiten"
|
||||||
|
confirmPopupMessage="Bist du sicher, dass du das Team bearbeiten möchtest?"
|
||||||
|
team={editTeamPopupTeam}
|
||||||
|
open={editTeamPopupTeam != null}
|
||||||
|
onSubmit={editTeam}
|
||||||
|
/>
|
||||||
|
{/key}
|
41
src/app/admin/teams/actions.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { teams } from './state.ts';
|
||||||
|
import type { Team } from './types.ts';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
export async function fetchTeams(name: string | null, username: string | null) {
|
||||||
|
const { data, error } = await actions.team.teams({ name: name, username: username });
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teams.set(data.teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTeam(team: Team) {
|
||||||
|
const { data, error } = await actions.team.addTeam(team);
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teams.update((old) => {
|
||||||
|
old.push(Object.assign(team, { id: data.id }));
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editTeam(team: Team) {
|
||||||
|
const { error } = await actions.team.editTeam(team);
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teams.update((old) => {
|
||||||
|
const index = old.findIndex((a) => a.id == team.id);
|
||||||
|
old[index] = team;
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
4
src/app/admin/teams/state.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import type { Teams } from './types.ts';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const teams = writable<Teams>([]);
|
6
src/app/admin/teams/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
export type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||||
|
export type Team = Teams[0];
|
||||||
|
|
||||||
|
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
95
src/app/admin/users/CreateOrEditPopup.svelte
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { User } from './types.ts';
|
||||||
|
import { userCreateOrEditPopupState } from './state.ts';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let modalForm: HTMLFormElement;
|
||||||
|
|
||||||
|
// input
|
||||||
|
let action = $state<'create' | 'edit' | null>(null);
|
||||||
|
let user = $state({} as User);
|
||||||
|
let onUpdate = $state((_: User) => {});
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
const cancel = userCreateOrEditPopupState.subscribe((value) => {
|
||||||
|
if (value && 'create' in value) {
|
||||||
|
action = 'create';
|
||||||
|
user = {
|
||||||
|
id: -1,
|
||||||
|
username: '',
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
birthday: new Date().toISOString().slice(0, 10),
|
||||||
|
telephone: '',
|
||||||
|
uuid: ''
|
||||||
|
};
|
||||||
|
onUpdate = value?.create.onUpdate;
|
||||||
|
modal.show();
|
||||||
|
} else if (value && 'edit' in value) {
|
||||||
|
action = 'edit';
|
||||||
|
user = value.edit.user;
|
||||||
|
onUpdate = value.edit.onUpdate;
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(cancel);
|
||||||
|
|
||||||
|
// texts
|
||||||
|
const texts = {
|
||||||
|
create: {
|
||||||
|
title: 'Nutzer erstellen',
|
||||||
|
buttonTitle: 'Erstellen',
|
||||||
|
confirmPopupTitle: 'Nutzer erstellen?',
|
||||||
|
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
title: 'Nutzer bearbeiten',
|
||||||
|
buttonTitle: 'Speichern',
|
||||||
|
confirmPopupTitle: 'Änderunge speichern?',
|
||||||
|
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||||
|
},
|
||||||
|
null: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onSaveButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: texts[action!].confirmPopupTitle,
|
||||||
|
message: texts[action!].confirmPopupMessage,
|
||||||
|
onConfirm: () => {
|
||||||
|
modalForm.submit();
|
||||||
|
onUpdate(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal}>
|
||||||
|
<form method="dialog" class="modal-box" bind:this={modalForm}>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<h3 class="text-xl font-geist font-bold">{texts[action!].title}</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
|
||||||
|
<Input type="text" bind:value={user.firstname} label="Vorname" />
|
||||||
|
<Input type="text" bind:value={user.lastname} label="Nachname" />
|
||||||
|
<Input type="date" bind:value={user.birthday} label="Geburtstag" />
|
||||||
|
<Input type="tel" bind:value={user.telephone} label="Telefonnummer" />
|
||||||
|
<Input type="text" bind:value={user.username} label="Spielername" />
|
||||||
|
<Input type="text" bind:value={user.uuid} label="UUID" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-success" onclick={onSaveButtonClick}>{texts[action!].buttonTitle}</button>
|
||||||
|
<button class="btn btn-error">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
34
src/app/admin/users/SidebarActions.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { userCreateOrEditPopupState } from './state.ts';
|
||||||
|
import { addUser, fetchUsers } from './actions.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
|
||||||
|
let usernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
fetchUsers({ username: usernameFilter });
|
||||||
|
});
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onNewUserButtonClick() {
|
||||||
|
$userCreateOrEditPopupState = {
|
||||||
|
create: {
|
||||||
|
onUpdate: addUser
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
|
||||||
|
<legend class="fieldset-legend">Filter</legend>
|
||||||
|
<Input bind:value={usernameFilter} label="Username" />
|
||||||
|
</fieldset>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<button class="btn btn-soft w-full" onclick={() => onNewUserButtonClick()}>
|
||||||
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
|
<span>Neuer Nutzer</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
56
src/app/admin/users/Users.svelte
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
||||||
|
import type { User } from './types.ts';
|
||||||
|
import { userCreateOrEditPopupState, users } from './state.ts';
|
||||||
|
import { editUser } from './actions.ts';
|
||||||
|
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||||
|
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onUserEditButtonClick(user: User) {
|
||||||
|
$userCreateOrEditPopupState = {
|
||||||
|
edit: {
|
||||||
|
user: user,
|
||||||
|
onUpdate: editUser
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<SortableTr data={users}>
|
||||||
|
<SortableTh style="width: 5%">#</SortableTh>
|
||||||
|
<SortableTh style="width: 15%" key="firstname">Vorname</SortableTh>
|
||||||
|
<SortableTh style="width: 15%" key="lastname">Nachname</SortableTh>
|
||||||
|
<SortableTh style="width: 5%" key="birthday">Geburtstag</SortableTh>
|
||||||
|
<SortableTh style="width: 12%" key="phone">Telefon</SortableTh>
|
||||||
|
<SortableTh style="width: 20%" key="username">Username</SortableTh>
|
||||||
|
<SortableTh style="width: 23%">UUID</SortableTh>
|
||||||
|
<SortableTh style="width: 5%"></SortableTh>
|
||||||
|
</SortableTr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $users as user, i (user.id)}
|
||||||
|
<tr class="hover:bg-base-200">
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td>{user.firstname}</td>
|
||||||
|
<td>{user.lastname}</td>
|
||||||
|
<td>{user.birthday}</td>
|
||||||
|
<td>{user.telephone}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.uuid}</td>
|
||||||
|
<td>
|
||||||
|
<button class="cursor-pointer" onclick={() => onUserEditButtonClick(user)}>
|
||||||
|
<Icon icon="heroicons:pencil-square" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateOrEditPopup />
|
56
src/app/admin/users/actions.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { actions } from 'astro:actions';
|
||||||
|
import { users } from './state.ts';
|
||||||
|
import type { User } from './types.ts';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
export async function fetchUsers(options?: { username?: string | null }) {
|
||||||
|
const { data, error } = await actions.user.users({ username: options?.username });
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.set(data.users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addUser(user: User) {
|
||||||
|
const { data, error } = await actions.user.addUser({
|
||||||
|
username: user.username,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
birthday: user.birthday,
|
||||||
|
telephone: user.telephone,
|
||||||
|
uuid: user.uuid
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.update((old) => {
|
||||||
|
old.push(Object.assign(user, { id: data.id }));
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editUser(user: User) {
|
||||||
|
const { error } = await actions.user.editUser({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
birthday: user.birthday,
|
||||||
|
telephone: user.telephone,
|
||||||
|
uuid: user.uuid
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.update((old) => {
|
||||||
|
const index = old.findIndex((a) => a.id == user.id);
|
||||||
|
old[index] = user;
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
6
src/app/admin/users/state.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import type { UserCreateOrEditPopupState, Users } from './types.ts';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const users = writable<Users>([]);
|
||||||
|
|
||||||
|
export const userCreateOrEditPopupState = writable<UserCreateOrEditPopupState>(null);
|
9
src/app/admin/users/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||||
|
export type User = Users[0];
|
||||||
|
|
||||||
|
export type UserCreateOrEditPopupState =
|
||||||
|
| { create: { onUpdate: (user: User) => void } }
|
||||||
|
| { edit: { user: User; onUpdate: (user: User) => void } }
|
||||||
|
| null;
|
62
src/app/webite/index/Teams.svelte
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Steve from '@assets/img/steve.png';
|
||||||
|
import Team from '@components/website/Team.svelte';
|
||||||
|
import type { GetDeathsRes } from '@db/schema/death.ts';
|
||||||
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||||
|
deaths: GetDeathsRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teams, deaths }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
|
||||||
|
<table class="table table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Spieler 1</th>
|
||||||
|
<th>Spieler 2</th>
|
||||||
|
<th>Kills</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each teams as team (team.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Team name={team.name} color={team.color} />
|
||||||
|
</td>
|
||||||
|
<td class="max-w-9 overflow-ellipsis">
|
||||||
|
{#if team.memberOne.id}
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
|
||||||
|
<span
|
||||||
|
class="text-xs sm:text-md"
|
||||||
|
class:line-through={deaths.find((d) => d.deadUserId === team.memberOne.id)}
|
||||||
|
>{team.memberOne.username}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if team.memberTwo.id}
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
|
||||||
|
<span
|
||||||
|
class="text-xs sm:text-md"
|
||||||
|
class:line-through={deaths.find((d) => d.deadUserId === team.memberTwo.id)}
|
||||||
|
>{team.memberTwo.username}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-xs sm:text-md">0</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
32
src/assets/admin_layout.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin 'daisyui' {
|
||||||
|
themes:
|
||||||
|
dark --default,
|
||||||
|
lofi;
|
||||||
|
logs: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Geist;
|
||||||
|
src: url('fonts/Geist.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: GeistMono;
|
||||||
|
src: url('fonts/GeistMono.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Minecraft;
|
||||||
|
src: url('./fonts/MinecraftRegular.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-geist: 'Geist', sans-serif;
|
||||||
|
--font-geist-mono: 'GeistMono', monospace;
|
||||||
|
--font-minecraft: 'Minecraft', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@apply font-geist-mono;
|
||||||
|
}
|
BIN
src/assets/fonts/Geist.ttf
Normal file
BIN
src/assets/fonts/GeistMono.ttf
Normal file
BIN
src/assets/fonts/MinecraftRegular.otf
Normal file
BIN
src/assets/img/background.webp
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
src/assets/img/menu-button.webp
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/img/menu-faq.webp
Normal file
After Width: | Height: | Size: 252 B |
BIN
src/assets/img/menu-feedback.webp
Normal file
After Width: | Height: | Size: 128 B |
BIN
src/assets/img/menu-home.webp
Normal file
After Width: | Height: | Size: 180 B |
BIN
src/assets/img/menu-inventory-bar.webp
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/img/menu-rules.webp
Normal file
After Width: | Height: | Size: 198 B |
BIN
src/assets/img/menu-selected-frame.webp
Normal file
After Width: | Height: | Size: 322 B |
BIN
src/assets/img/menu-signup.webp
Normal file
After Width: | Height: | Size: 216 B |
BIN
src/assets/img/menu-team.webp
Normal file
After Width: | Height: | Size: 156 B |
BIN
src/assets/img/skeleton.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/img/steve.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/img/varo.webp
Normal file
After Width: | Height: | Size: 6.4 KiB |
41
src/assets/website_layout.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin 'daisyui' {
|
||||||
|
themes:
|
||||||
|
dark --default,
|
||||||
|
lofi;
|
||||||
|
logs: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Geist;
|
||||||
|
src: url('fonts/Geist.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: GeistMono;
|
||||||
|
src: url('fonts/GeistMono.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Minecraft;
|
||||||
|
src: url('./fonts/MinecraftRegular.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-geist: 'Geist', sans-serif;
|
||||||
|
--font-geist-mono: 'GeistMono', monospace;
|
||||||
|
--font-minecraft: 'Minecraft', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@apply font-geist;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-minecraft;
|
||||||
|
}
|
205
src/components/Welcome.astro
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
---
|
||||||
|
import astroLogo from '@assets/astro.svg';
|
||||||
|
import background from '@assets/background.svg';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="container">
|
||||||
|
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||||
|
<main>
|
||||||
|
<section id="hero">
|
||||||
|
<a href="https://astro.build"><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a>
|
||||||
|
<h1>
|
||||||
|
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||||
|
</h1>
|
||||||
|
<section id="links">
|
||||||
|
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||||
|
<a href="https://astro.build/chat"
|
||||||
|
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||||
|
><path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||||
|
></path></svg
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||||
|
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
><path
|
||||||
|
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||||
|
fill="#111827"></path></svg
|
||||||
|
>
|
||||||
|
<h2>What's New in Astro 5.0?</h2>
|
||||||
|
<p>
|
||||||
|
From content layers to server islands, click to learn more about the new features and improvements in Astro 5.0
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#background {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
filter: blur(100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #111827;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a:hover {
|
||||||
|
color: rgb(78, 80, 86);
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a svg {
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a.button {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||||
|
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a.button:hover {
|
||||||
|
color: rgb(230, 230, 230);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||||
|
font-weight: normal;
|
||||||
|
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline-block;
|
||||||
|
background:
|
||||||
|
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||||
|
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#news {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
max-width: 300px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
backdrop-filter: blur(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#news:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 368px) {
|
||||||
|
#news {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
#container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hero {
|
||||||
|
display: block;
|
||||||
|
padding-top: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a.button {
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#news {
|
||||||
|
right: 16px;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 2.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
97
src/components/admin/search/Search.svelte
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
mustMatch?: boolean;
|
||||||
|
|
||||||
|
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
|
||||||
|
|
||||||
|
onSubmit?: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let inputValue = $state(value);
|
||||||
|
let suggestions = $state<string[]>([]);
|
||||||
|
let matched = $state(false);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
async function onBodyMouseDown(e: MouseEvent) {
|
||||||
|
if (!container.contains(e.target as Node)) suggestions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSearchInput() {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
suggestions = await requestSuggestions(inputValue ?? '', 5);
|
||||||
|
|
||||||
|
let suggestion = suggestions.find((s) => s === inputValue);
|
||||||
|
if (suggestion != null) {
|
||||||
|
inputValue = value = suggestion;
|
||||||
|
matched = true;
|
||||||
|
onSubmit?.(value);
|
||||||
|
} else if (!mustMatch) {
|
||||||
|
value = inputValue;
|
||||||
|
matched = false;
|
||||||
|
} else {
|
||||||
|
value = null;
|
||||||
|
matched = false;
|
||||||
|
onSubmit?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSuggestionClick(suggestion: string) {
|
||||||
|
inputValue = value = suggestion;
|
||||||
|
suggestions = [];
|
||||||
|
onSubmit?.(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:body onmousedown={onBodyMouseDown} />
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</legend>
|
||||||
|
<div class="relative" bind:this={container}>
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{readonly}
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
class="input"
|
||||||
|
bind:value={inputValue}
|
||||||
|
oninput={() => onSearchInput()}
|
||||||
|
onfocusin={() => onSearchInput()}
|
||||||
|
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
|
||||||
|
/>
|
||||||
|
{#if suggestions.length > 0}
|
||||||
|
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||||
|
{#each suggestions as suggestion (suggestion)}
|
||||||
|
<li class="w-full text-left">
|
||||||
|
<button
|
||||||
|
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
title={suggestion}
|
||||||
|
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="fieldset-label"></p>
|
||||||
|
</fieldset>
|
61
src/components/admin/search/TeamSearch.svelte
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
import Search from '@components/admin/search/Search.svelte';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||||
|
type Team = Teams[0];
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
mustMatch?: boolean;
|
||||||
|
|
||||||
|
onSubmit?: (team: Team | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let teamSuggestionCache = $state<Teams>([]);
|
||||||
|
|
||||||
|
// functions
|
||||||
|
async function getSuggestions(query: string, limit: number) {
|
||||||
|
const { data, error } = await actions.team.teams({
|
||||||
|
name: query,
|
||||||
|
limit: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
teamSuggestionCache = data.teams;
|
||||||
|
return teamSuggestionCache.map((team) => team.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamByTeamName(teamName: string) {
|
||||||
|
let team = teamSuggestionCache.find((team) => team.name === teamName);
|
||||||
|
if (!team) {
|
||||||
|
await getSuggestions(teamName, 5);
|
||||||
|
return await getTeamByTeamName(teamName);
|
||||||
|
}
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Search
|
||||||
|
{id}
|
||||||
|
bind:value
|
||||||
|
{label}
|
||||||
|
{readonly}
|
||||||
|
{required}
|
||||||
|
{mustMatch}
|
||||||
|
requestSuggestions={async (teamName) => getSuggestions(teamName, 5)}
|
||||||
|
onSubmit={async (teamName) => onSubmit?.(teamName != null ? await getTeamByTeamName(teamName) : null)}
|
||||||
|
/>
|
61
src/components/admin/search/UserSearch.svelte
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
import Search from '@components/admin/search/Search.svelte';
|
||||||
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||||
|
type User = Users[0];
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
mustMatch?: boolean;
|
||||||
|
|
||||||
|
onSubmit?: (user: User | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
// states
|
||||||
|
let userSuggestionCache = $state<Users>([]);
|
||||||
|
|
||||||
|
// functions
|
||||||
|
async function getSuggestions(query: string, limit: number) {
|
||||||
|
const { data, error } = await actions.user.users({
|
||||||
|
username: query,
|
||||||
|
limit: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
actionErrorPopup(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
userSuggestionCache = data.users;
|
||||||
|
return userSuggestionCache.map((user) => user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserByUsername(username: string) {
|
||||||
|
let user = userSuggestionCache.find((user) => user.username === username);
|
||||||
|
if (!user) {
|
||||||
|
await getSuggestions(username, 5);
|
||||||
|
return await getUserByUsername(username);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Search
|
||||||
|
{id}
|
||||||
|
bind:value
|
||||||
|
{label}
|
||||||
|
{readonly}
|
||||||
|
{required}
|
||||||
|
{mustMatch}
|
||||||
|
requestSuggestions={async (username) => getSuggestions(username, 5)}
|
||||||
|
onSubmit={async (username) => onSubmit?.(username != null ? await getUserByUsername(username) : null)}
|
||||||
|
/>
|
48
src/components/admin/table/SortableTh.svelte
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, type Snippet } from 'svelte';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
key?: string;
|
||||||
|
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableHeaderContext {
|
||||||
|
headerKey: Writable<string>;
|
||||||
|
onSort: (key: string, order: 'asc' | 'desc') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
const { key, children, ...restProps }: Props & Record<string, any> = $props();
|
||||||
|
|
||||||
|
let { headerKey, onSort }: SortableHeaderContext = getContext('sortableHeader');
|
||||||
|
|
||||||
|
let asc = $state(false);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onButtonClick() {
|
||||||
|
if (key == undefined) return;
|
||||||
|
|
||||||
|
$headerKey = key;
|
||||||
|
asc = !asc;
|
||||||
|
onSort(key, asc ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<th {...restProps}>
|
||||||
|
{#if key}
|
||||||
|
<button class="flex items-center gap-1" onclick={() => onButtonClick()}>
|
||||||
|
<span>{@render children?.()}</span>
|
||||||
|
{#if $headerKey === key && asc}
|
||||||
|
<Icon icon="heroicons:chevron-up-16-solid" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="heroicons:chevron-down-16-solid" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
|
</th>
|
57
src/components/admin/table/SortableTr.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setContext, type Snippet } from 'svelte';
|
||||||
|
import { type Writable, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props {
|
||||||
|
data: Writable<{ [key: string]: any }[]>;
|
||||||
|
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
const { data, children, ...restProps }: Props & Record<string, any> = $props();
|
||||||
|
|
||||||
|
setContext('sortableHeader', {
|
||||||
|
headerKey: writable(null),
|
||||||
|
onSort: onSort
|
||||||
|
});
|
||||||
|
|
||||||
|
// functions
|
||||||
|
function onSort(key: string, order: 'asc' | 'desc') {
|
||||||
|
data.update((old) => {
|
||||||
|
old.sort((a, b) => {
|
||||||
|
let entryA = getDataEntryByKey(key, a);
|
||||||
|
let entryB = getDataEntryByKey(key, b);
|
||||||
|
|
||||||
|
if (entryA === undefined || entryB === undefined) return 0;
|
||||||
|
|
||||||
|
if (typeof entryA === 'string') entryA = entryA.toLowerCase();
|
||||||
|
if (typeof entryB === 'string') entryB = entryB.toLowerCase();
|
||||||
|
|
||||||
|
if (order === 'asc') {
|
||||||
|
return entryA < entryB ? -1 : 1;
|
||||||
|
} else if (order === 'desc') {
|
||||||
|
return entryA > entryB ? -1 : 1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return old;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||||
|
let entry = data;
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if ((entry = entry[part]) === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<tr {...restProps}>
|
||||||
|
{@render children()}
|
||||||
|
</tr>
|
51
src/components/input/Checkbox.svelte
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
checked?: boolean | null;
|
||||||
|
required?: boolean;
|
||||||
|
validation?: {
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
label?: Snippet | string;
|
||||||
|
notice?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, checked = $bindable(), required, validation, disabled, label, notice }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
name={id}
|
||||||
|
bind:checked
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
class:validator={required || validation}
|
||||||
|
required={required ? true : null}
|
||||||
|
disabled={disabled ? true : null}
|
||||||
|
/>
|
||||||
|
<span class="ml-1">
|
||||||
|
{#if typeof label === 'string'}
|
||||||
|
<span>{label}</span>
|
||||||
|
{:else if label}
|
||||||
|
{@render label()}
|
||||||
|
{/if}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="fieldset-label">
|
||||||
|
{#if notice}
|
||||||
|
{@render notice()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if validation}
|
||||||
|
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
75
src/components/input/Input.svelte
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
type?: 'color' | 'date' | 'tel' | 'text' | 'email';
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
validation?: {
|
||||||
|
min?: string;
|
||||||
|
max?: string;
|
||||||
|
pattern?: string;
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
hidden?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
size?: 'sm';
|
||||||
|
dynamicWidth?: boolean;
|
||||||
|
|
||||||
|
notice?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
value = $bindable(),
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
validation,
|
||||||
|
hidden,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
size,
|
||||||
|
dynamicWidth,
|
||||||
|
notice
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset" {hidden}>
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</legend>
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
name={id}
|
||||||
|
bind:value
|
||||||
|
class="input"
|
||||||
|
class:input-sm={size === 'sm'}
|
||||||
|
class:validator={required || validation}
|
||||||
|
class:w-full={dynamicWidth}
|
||||||
|
type={type || 'text'}
|
||||||
|
min={validation?.min}
|
||||||
|
max={validation?.max}
|
||||||
|
required={required ? true : null}
|
||||||
|
pattern={validation?.pattern}
|
||||||
|
readonly={readonly ? true : null}
|
||||||
|
disabled={disabled ? true : null}
|
||||||
|
/>
|
||||||
|
<p class="fieldset-label">
|
||||||
|
{#if notice}
|
||||||
|
{@render notice()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if validation}
|
||||||
|
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
70
src/components/input/Password.svelte
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
validation?: {
|
||||||
|
pattern?: string;
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
notice?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, value = $bindable(), label, required, validation, disabled, notice }: Props = $props();
|
||||||
|
|
||||||
|
let visible = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</legend>
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
bind:value
|
||||||
|
class="input pr-9"
|
||||||
|
class:validator={required || validation}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
required={required ? true : null}
|
||||||
|
pattern={validation?.pattern}
|
||||||
|
disabled={disabled ? true : null}
|
||||||
|
data-input-visible="false"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 cursor-pointer z-10"
|
||||||
|
class:hidden={!visible}
|
||||||
|
onclick={() => (visible = !visible)}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:eye-16-solid" width={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 cursor-pointer z-10"
|
||||||
|
class:hidden={visible}
|
||||||
|
onclick={() => (visible = !visible)}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:eye-slash-16-solid" width={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="fieldset-label">
|
||||||
|
{#if notice}
|
||||||
|
{@render notice()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if validation}
|
||||||
|
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
71
src/components/input/Select.svelte
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
values: { [value: string]: string };
|
||||||
|
defaultValue?: string;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
validation?: {
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
size?: 'sm';
|
||||||
|
dynamicWidth?: boolean;
|
||||||
|
|
||||||
|
notice?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
value = $bindable(),
|
||||||
|
values,
|
||||||
|
defaultValue,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
validation,
|
||||||
|
disabled,
|
||||||
|
size,
|
||||||
|
dynamicWidth,
|
||||||
|
notice
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</legend>
|
||||||
|
<select
|
||||||
|
{id}
|
||||||
|
bind:value
|
||||||
|
class="select"
|
||||||
|
class:select-sm={size === 'sm'}
|
||||||
|
class:w-full={dynamicWidth}
|
||||||
|
class:validator={required || validation}
|
||||||
|
required={required ? true : null}
|
||||||
|
disabled={disabled ? true : null}
|
||||||
|
>
|
||||||
|
{#if defaultValue != null}
|
||||||
|
<option disabled selected>{defaultValue}</option>
|
||||||
|
{/if}
|
||||||
|
{#each Object.entries(values) as [value, label] (value)}
|
||||||
|
<option {value}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="fieldset-label">
|
||||||
|
{#if notice}
|
||||||
|
{@render notice()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if validation}
|
||||||
|
<p class="validator-hint mt-0">{validation.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
36
src/components/input/Textarea.svelte
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value?: string | null;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
|
||||||
|
size?: 'sm';
|
||||||
|
dynamicWidth?: boolean;
|
||||||
|
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, value = $bindable(), label, required, readonly, size, dynamicWidth, rows }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-700">*</span>
|
||||||
|
{/if}
|
||||||
|
</legend>
|
||||||
|
<textarea
|
||||||
|
{id}
|
||||||
|
class="textarea"
|
||||||
|
class:textarea-sm={size === 'sm'}
|
||||||
|
class:w-full={dynamicWidth}
|
||||||
|
class:validator={required}
|
||||||
|
bind:value
|
||||||
|
{required}
|
||||||
|
{rows}
|
||||||
|
{readonly}
|
||||||
|
></textarea>
|
||||||
|
</fieldset>
|
33
src/components/popup/ConfirmPopup.svelte
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
const cancel = confirmPopupState.subscribe((value) => {
|
||||||
|
if (value) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(cancel);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onModalClose() {
|
||||||
|
setTimeout(() => ($confirmPopupState = null), 300);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||||
|
<form method="dialog" class="modal-box">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-geist">{$confirmPopupState?.title}</h3>
|
||||||
|
<p class="py-4 whitespace-pre-line">{$confirmPopupState?.message}</p>
|
||||||
|
<button class="btn" onclick={() => $confirmPopupState?.onConfirm()}>Ok</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
3
src/components/popup/ConfirmPopup.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const confirmPopupState = atom<{ title: string; message: string; onConfirm: () => void } | null>(null);
|
33
src/components/popup/Popup.svelte
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { popupState } from '@components/popup/Popup.ts';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
const cancel = popupState.subscribe((value) => {
|
||||||
|
if (value) modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(cancel);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onModalClose() {
|
||||||
|
setTimeout(() => ($popupState = null), 300);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||||
|
<form method="dialog" class="modal-box">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-geist">{$popupState?.title}</h3>
|
||||||
|
<p class="py-4 whitespace-pre-line">{$popupState?.message}</p>
|
||||||
|
<button class="btn" class:btn-error={$popupState?.type === 'error'}>Ok</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
3
src/components/popup/Popup.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string } | null>(null);
|
13
src/components/website/Team.svelte
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, color }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<div class="rounded-sm w-3 h-3" style="background-color: {color}"></div>
|
||||||
|
<h3 class="text-xs sm:text-xl">{name}</h3>
|
||||||
|
</div>
|
69
src/components/website/index/Countdown.svelte
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
let { start, end }: { start?: number; end: number } = $props();
|
||||||
|
|
||||||
|
let title = `Spielstart ist am ${new Date(import.meta.env.PUBLIC_START_DATE).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})} Uhr`;
|
||||||
|
|
||||||
|
function getUntil(): [number, number, number, number] {
|
||||||
|
let diff = (end - (start || Date.now())) / 1000;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.floor(diff / (60 * 60 * 24)),
|
||||||
|
Math.floor((diff % (60 * 60 * 24)) / (60 * 60)),
|
||||||
|
Math.floor((diff % (60 * 60)) / 60),
|
||||||
|
Math.floor(diff % 60)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let [days, hours, minutes, seconds] = $state(getUntil());
|
||||||
|
let intervalId = setInterval(() => {
|
||||||
|
[days, hours, minutes, seconds] = getUntil();
|
||||||
|
if (start) start += 1000;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
onDestroy(() => clearInterval(intervalId));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class:hidden={days + hours + minutes + seconds < 0}
|
||||||
|
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||||
|
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||||
|
<span class="m-auto" style="--value:{days};"></span>
|
||||||
|
</span>
|
||||||
|
Tage
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||||
|
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||||
|
<span class="m-auto" style="--value:{hours};"></span>
|
||||||
|
</span>
|
||||||
|
Stunden
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||||
|
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||||
|
<span class="m-auto" style="--value:{minutes};"></span>
|
||||||
|
</span>
|
||||||
|
Minuten
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
|
||||||
|
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||||
|
<span class="m-auto" style="--value:{seconds};"></span>
|
||||||
|
</span>
|
||||||
|
Sekunden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Set a custom content for the countdown before selector as it only supports numbers up to 99 */
|
||||||
|
.countdown > ::before {
|
||||||
|
content: '00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A 100\A 101\A 102\A 103\A 104\A 105\A 106\A 107\A 108\A 109\A 110\A 111\A 112\A 113\A 114\A 115\A 116\A 117\A 118\A 119\A 120\A 121\A 122\A 123\A 124\A 125\A 126\A 127\A 128\A 129\A 130\A 131\A 132\A 133\A 134\A 135\A 136\A 137\A 138\A 139\A 140\A 141\A 142\A 143\A 144\A 145\A 146\A 147\A 148\A 149\A 150\A 151\A 152\A 153\A 154\A 155\A 156\A 157\A 158\A 159\A 160\A 161\A 162\A 163\A 164\A 165\A 166\A 167\A 168\A 169\A 170\A 171\A 172\A 173\A 174\A 175\A 176\A 177\A 178\A 179\A 180\A 181\A 182\A 183\A 184\A 185\A 186\A 187\A 188\A 189\A 190\A 191\A 192\A 193\A 194\A 195\A 196\A 197\A 198\A 199\A 200\A 201\A 202\A 203\A 204\A 205\A 206\A 207\A 208\A 209\A 210\A 211\A 212\A 213\A 214\A 215\A 216\A 217\A 218\A 219\A 220\A 221\A 222\A 223\A 224\A 225\A 226\A 227\A 228\A 229\A 230\A 231\A 232\A 233\A 234\A 235\A 236\A 237\A 238\A 239\A 240\A 241\A 242\A 243\A 244\A 245\A 246\A 247\A 248\A 249\A 250\A 251\A 252\A 253\A 254\A 255\A 256\A 257\A 258\A 259\A 260\A 261\A 262\A 263\A 264\A 265\A 266\A 267\A 268\A 269\A 270\A 271\A 272\A 273\A 274\A 275\A 276\A 277\A 278\A 279\A 280\A 281\A 282\A 283\A 284\A 285\A 286\A 287\A 288\A 289\A 290\A 291\A 292\A 293\A 294\A 295\A 296\A 297\A 298\A 299\A 300\A 301\A 302\A 303\A 304\A 305\A 306\A 307\A 308\A 309\A 310\A 311\A 312\A 313\A 314\A 315\A 316\A 317\A 318\A 319\A 320\A 321\A 322\A 323\A 324\A 325\A 326\A 327\A 328\A 329\A 330\A 331\A 332\A 333\A 334\A 335\A 336\A 337\A 338\A 339\A 340\A 341\A 342\A 343\A 344\A 345\A 346\A 347\A 348\A 349\A 350\A 351\A 352\A 353\A 354\A 355\A 356\A 357\A 358\A 359\A 360\A 361\A 362\A 363\A 364\A';
|
||||||
|
}
|
||||||
|
</style>
|
39
src/components/website/index/Scroll.svelte
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { href } = $props();
|
||||||
|
|
||||||
|
let scrollY = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:scrollY />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-x-2 transition-opacity duration-250" class:opacity-0={scrollY > 0}>
|
||||||
|
<div class="divider divider-horizontal m-0"></div>
|
||||||
|
<a {href} aria-label="scroll to teams">
|
||||||
|
<div class="border-accent border-2 rounded-t-full rounded-b-full h-7 w-4 p-1">
|
||||||
|
<div class="bg-accent rounded-full h-1 w-1 bounce"></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="link text-sm" {href}>Zu den Teams</a>
|
||||||
|
<div class="divider divider-horizontal m-0"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes scrollDown {
|
||||||
|
0% {
|
||||||
|
transform: none;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(250%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce {
|
||||||
|
animation: scrollDown 2s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
180
src/components/website/layout/Menu.svelte
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BASE_PATH } from 'astro:env/client';
|
||||||
|
import MenuHome from '@assets/img/menu-home.webp';
|
||||||
|
import MenuSignup from '@assets/img/menu-signup.webp';
|
||||||
|
import MenuRules from '@assets/img/menu-rules.webp';
|
||||||
|
import MenuFaq from '@assets/img/menu-faq.webp';
|
||||||
|
import MenuFeedback from '@assets/img/menu-feedback.webp';
|
||||||
|
import MenuTeam from '@assets/img/menu-team.webp';
|
||||||
|
import MenuButton from '@assets/img/menu-button.webp';
|
||||||
|
import MenuInventoryBar from '@assets/img/menu-inventory-bar.webp';
|
||||||
|
import MenuSelectedFrame from '@assets/img/menu-selected-frame.webp';
|
||||||
|
import { isBrowser } from '@antfu/utils';
|
||||||
|
import { navigate } from 'astro:transitions/client';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let navPaths = $state([
|
||||||
|
{
|
||||||
|
name: 'Startseite',
|
||||||
|
sprite: MenuHome.src,
|
||||||
|
href: `${BASE_PATH}/`,
|
||||||
|
active: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Registrieren',
|
||||||
|
sprite: MenuSignup.src,
|
||||||
|
href: `${BASE_PATH}/signup`,
|
||||||
|
active: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Regeln',
|
||||||
|
sprite: MenuRules.src,
|
||||||
|
href: `${BASE_PATH}/rules`,
|
||||||
|
active: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FAQ',
|
||||||
|
sprite: MenuFaq.src,
|
||||||
|
href: `${BASE_PATH}/faq`,
|
||||||
|
active: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Feedback & Kontakt',
|
||||||
|
sprite: MenuFeedback.src,
|
||||||
|
href: `${BASE_PATH}/feedback`,
|
||||||
|
active: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Team',
|
||||||
|
sprite: MenuTeam.src,
|
||||||
|
href: `${BASE_PATH}/team`,
|
||||||
|
active: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let showMenuPermanent = $state(isBrowser ? localStorage.getItem('showMenuPermanent') === 'true' : false);
|
||||||
|
let isTouch = $state(false);
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let windowHeight = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem('showMenuPermanent', `${showMenuPermanent}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
new MutationObserver(() => {
|
||||||
|
for (let i = 0; i < navPaths.length; i++) {
|
||||||
|
console.log(navPaths[i].href, window.location.pathname);
|
||||||
|
navPaths[i].active = new URL(navPaths[i].href).pathname === window.location.pathname;
|
||||||
|
}
|
||||||
|
}).observe(document.head, { childList: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
let navElem: HTMLDivElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerHeight={windowHeight} />
|
||||||
|
<svelte:body
|
||||||
|
ontouchend={(e) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50 main-menu"
|
||||||
|
bind:this={navElem}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class={isTouch ? 'btn btn-square relative w-16 h-16' : 'btn btn-square group/menu-button relative w-16 h-16'}
|
||||||
|
onclick={() => {
|
||||||
|
if (!isTouch) {
|
||||||
|
let activePath = navPaths.find((path) => path.active);
|
||||||
|
if (activePath !== undefined) {
|
||||||
|
navigate(activePath.href);
|
||||||
|
}
|
||||||
|
showMenuPermanent = !showMenuPermanent;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ontouchend={() => {
|
||||||
|
isTouch = true;
|
||||||
|
showMenuPermanent = !showMenuPermanent;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img class="absolute w-full h-full p-1 pixelated" src={MenuButton.src} alt="menu" />
|
||||||
|
<img
|
||||||
|
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated"
|
||||||
|
class:opacity-100={isOpen || (isTouch && showMenuPermanent)}
|
||||||
|
src={MenuSelectedFrame.src}
|
||||||
|
alt="menu hover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class:hidden={!(isOpen || showMenuPermanent)}
|
||||||
|
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
|
||||||
|
onmouseenter={() => (isOpen = true)}
|
||||||
|
onmouseleave={() => (isOpen = false)}
|
||||||
|
>
|
||||||
|
<ul class="bg-base-200 rounded">
|
||||||
|
{#each navPaths as navPath, i (navPath.href)}
|
||||||
|
<li
|
||||||
|
class="flex justify-center tooltip"
|
||||||
|
class:tooltip-left={windowHeight > 450}
|
||||||
|
class:sm:tooltip-right={windowHeight > 450}
|
||||||
|
class:tooltip-top={windowHeight <= 450}
|
||||||
|
class:tooltip-open={isTouch || windowHeight <= 450}
|
||||||
|
data-tip={navPath.name}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
|
||||||
|
href={navPath.href}
|
||||||
|
onclick={() => navigate(navPath.href)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="background-image: url({MenuInventoryBar.src}); background-position: -{i * 3.5}rem 0;"
|
||||||
|
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated"
|
||||||
|
></div>
|
||||||
|
<div class="absolute flex justify-center items-center w-full h-full">
|
||||||
|
<img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10"
|
||||||
|
class:opacity-0={!navPath.active}
|
||||||
|
src={MenuSelectedFrame.src}
|
||||||
|
alt="menu hover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-height: 450px) {
|
||||||
|
.main-menu {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.main-menu > div {
|
||||||
|
padding: 0.25rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.main-menu li {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform-origin: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixelated {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-horizontal-sprite {
|
||||||
|
background-size: auto 100%;
|
||||||
|
}
|
||||||
|
</style>
|
94
src/components/website/signup/RegisteredPopup.svelte
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { registeredPopupState } from '@components/website/signup/RegisteredPopup.ts';
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import { BASE_PATH, DISCORD_LINK, PAYPAL_LINK, START_DATE, TEAMSPEAK_LINK } from 'astro:env/client';
|
||||||
|
|
||||||
|
let skin: string | null = $state(null);
|
||||||
|
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
|
||||||
|
registeredPopupState.subscribe(async (value) => {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
const skinview3d = await import('skinview3d');
|
||||||
|
const skinViewer = new skinview3d.SkinViewer({
|
||||||
|
width: 200,
|
||||||
|
height: 300,
|
||||||
|
renderPaused: true
|
||||||
|
});
|
||||||
|
|
||||||
|
skinViewer.camera.rotation.x = -0.62;
|
||||||
|
skinViewer.camera.rotation.y = 0.534;
|
||||||
|
skinViewer.camera.rotation.z = 0.348;
|
||||||
|
skinViewer.camera.position.x = 30.5;
|
||||||
|
skinViewer.camera.position.y = 22.0;
|
||||||
|
skinViewer.camera.position.z = 42.0;
|
||||||
|
|
||||||
|
await skinViewer.loadSkin(`https://mc-heads.net/skin/${value.username}`);
|
||||||
|
skinViewer.render();
|
||||||
|
skin = skinViewer.canvas.toDataURL();
|
||||||
|
|
||||||
|
skinViewer.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={($registeredPopupState = null)}>
|
||||||
|
<form method="dialog" class="modal-box xl:w-5/12 max-w-10/12 z-10">
|
||||||
|
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
|
||||||
|
<p class="text-center font-bold">
|
||||||
|
<span>Du hast Dich erfolgreich mit dem Team </span>
|
||||||
|
<span class="inline-flex rounded-sm w-3 h-3" style="background-color: {$registeredPopupState?.teamColor}"></span>
|
||||||
|
<span>{$registeredPopupState?.team}</span>
|
||||||
|
<span> für Varo 4 registriert</span>. Spielstart ist am
|
||||||
|
<i>
|
||||||
|
{new Date(START_DATE).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||||
|
</i>
|
||||||
|
um
|
||||||
|
<i>
|
||||||
|
{new Date(START_DATE).toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr
|
||||||
|
</i>.
|
||||||
|
</p>
|
||||||
|
<p class="text-center">Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
Falls du uns unterstützen möchtest, kannst du dies ganz einfach über
|
||||||
|
<a class="link" href={PAYPAL_LINK} target="_blank">PayPal</a>
|
||||||
|
tun. Antworten auf häufig gestellte Fragen findest du in unserer
|
||||||
|
<a class="link" href="{BASE_PATH}/faq" target="_blank">FAQ</a>. Außerdem freuen wir uns, dich auf unserem
|
||||||
|
<a class="link" href={TEAMSPEAK_LINK} target="_blank">TeamSpeak</a>
|
||||||
|
oder in unserem
|
||||||
|
<a class="link" href={DISCORD_LINK} target="_blank">Discord</a>
|
||||||
|
begrüßen zu dürfen!
|
||||||
|
</p>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="flex justify-around mt-2 mb-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
|
||||||
|
<Input type="text" value={$registeredPopupState?.firstname} label="Vorname" disabled />
|
||||||
|
<Input type="text" value={$registeredPopupState?.lastname} label="Nachname" disabled />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={$registeredPopupState?.birthday.toISOString().substring(0, 10)}
|
||||||
|
label="Geburtstag"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input type="tel" value={$registeredPopupState?.phone} label="Telefonnummer" disabled />
|
||||||
|
<Input type="text" value={$registeredPopupState?.username} label="Spielername" disabled />
|
||||||
|
<Input type="text" value={$registeredPopupState?.teamMember} label="Mitspieler" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="relative hidden md:flex justify-center w-[200px] my-4">
|
||||||
|
{#if skin}
|
||||||
|
<img class="absolute" src={skin} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="flex justify-center gap-8">
|
||||||
|
<button class="btn">Weitere Person anmelden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="absolute w-full h-full bg-black/50"></div>
|
||||||
|
</dialog>
|
12
src/components/website/signup/RegisteredPopup.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const registeredPopupState = atom<{
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
birthday: Date;
|
||||||
|
phone: string;
|
||||||
|
username: string;
|
||||||
|
team: string;
|
||||||
|
teamMember: string;
|
||||||
|
teamColor: string;
|
||||||
|
} | null>(null);
|
84
src/components/website/signup/RulesPopup.svelte
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { rulesPopupState, rulesPopupRead } from './RulesPopup.ts';
|
||||||
|
import { rulesShort } from '../../../rules.ts';
|
||||||
|
|
||||||
|
const modalTimeoutSeconds = 30;
|
||||||
|
|
||||||
|
let modalElem: HTMLDialogElement;
|
||||||
|
|
||||||
|
let modalTimer = $state(null);
|
||||||
|
let modalSecondsOpen = $state(import.meta.env.PROD ? 0 : modalTimeoutSeconds);
|
||||||
|
|
||||||
|
rulesPopupState.listen((value) => {
|
||||||
|
if (value == 'open') {
|
||||||
|
modalElem.show();
|
||||||
|
setInterval(() => modalSecondsOpen++, 1000);
|
||||||
|
} else if (value == 'closed') {
|
||||||
|
clearInterval(modalTimer!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
id="rules-popup"
|
||||||
|
class="modal"
|
||||||
|
onclose={() => {
|
||||||
|
if ($rulesPopupState !== 'accepted') $rulesPopupState = 'closed';
|
||||||
|
}}
|
||||||
|
bind:this={modalElem}
|
||||||
|
>
|
||||||
|
<form method="dialog" class="modal-box flex flex-col max-h-[90%] max-w-[95%] md:max-w-[90%] lg:max-w-[75%]">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
<div class="overflow-auto mt-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" autocomplete="off" checked />
|
||||||
|
<div class="collapse-title">
|
||||||
|
<p>0. Vorwort</p>
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<p>{rulesShort.header}</p>
|
||||||
|
<p class="mt-1 text-[.75rem]">{rulesShort.footer}</p>
|
||||||
|
</div>
|
||||||
|
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||||
|
</div>
|
||||||
|
{#each rulesShort.sections as section, i (section.title)}
|
||||||
|
<div class="collapse collapse-arrow">
|
||||||
|
<input type="checkbox" autocomplete="off" />
|
||||||
|
<div class="collapse-title">
|
||||||
|
<p>{i + 1}. {section.title}</p>
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<p>{section.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative w-min"
|
||||||
|
title={modalSecondsOpen < modalTimeoutSeconds
|
||||||
|
? `Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
|
<!--div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
|
||||||
|
<div
|
||||||
|
style="width: {Math.min((modalSecondsOpen / modalTimeoutSeconds) * 100, 100)}%"
|
||||||
|
class="h-full bg-base-300"
|
||||||
|
></div>
|
||||||
|
</div-->
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral"
|
||||||
|
disabled={modalSecondsOpen < modalTimeoutSeconds}
|
||||||
|
onclick={() => {
|
||||||
|
$rulesPopupRead = true;
|
||||||
|
$rulesPopupState = 'accepted';
|
||||||
|
}}>Akzeptieren</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||||
|
<button class="!cursor-default">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
4
src/components/website/signup/RulesPopup.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const rulesPopupState = atom<'open' | 'closed' | 'accepted'>('closed');
|
||||||
|
export const rulesPopupRead = atom(false);
|
30
src/components/website/signup/TeamPopup.svelte
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { teamPopupOpen, teamPopupName } from '@components/website/signup/TeamPopup.ts';
|
||||||
|
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
|
||||||
|
teamPopupOpen.subscribe((value) => {
|
||||||
|
if (value) modal.show();
|
||||||
|
else form?.reset();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={() => ($teamPopupOpen = false)}>
|
||||||
|
<div class="modal-box">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<form method="dialog" bind:this={form} onsubmit={() => ($teamPopupName = form.teamName.value)}>
|
||||||
|
<h3 class="text-lg font-geist">Team erstellen</h3>
|
||||||
|
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt.</p>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">
|
||||||
|
<span>Teamname <span class="text-red-700">*</span></span>
|
||||||
|
</legend>
|
||||||
|
<input id="teamName" name="teamName" class="input validator" type="text" required />
|
||||||
|
</fieldset>
|
||||||
|
<button class="mt-4 btn btn-neutral">Team registrieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
4
src/components/website/signup/TeamPopup.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export const teamPopupOpen = atom(false);
|
||||||
|
export const teamPopupName = atom<string | null>(null);
|