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);
|