initial commit
Some checks failed
deploy / build-and-deploy (push) Failing after 21s

This commit is contained in:
bytedream 2025-05-18 13:16:20 +02:00
commit 60f3f8a096
148 changed files with 17900 additions and 0 deletions

5
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,5 @@
FROM node:22
WORKDIR /workspace
USER node

View 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"]
}
}
}

View 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
View 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

View 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
View 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
View 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
View 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>&nbsp;&nbsp;// UUIDs aller Nutzer des Teams, das einen Strike bekommen hat<br>&nbsp;&nbsp;"users": string[],<br>&nbsp;&nbsp;// Gewichtung aller Strikes, die das Team insgesamt bekommen hat<br>&nbsp;&nbsp;"totalWeight": number<br>}</pre> |

53
astro.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
};
}
})
};

View 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}

View 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>

View 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>

View 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}

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

View File

@ -0,0 +1,4 @@
import type { Admin } from './types.ts';
import { writable } from 'svelte/store';
export const admins = writable<Admin[]>([]);

View 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];

View 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>

View 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} />

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

View File

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { Feedbacks } from './types.ts';
export const feedbacks = writable<Feedbacks>([]);

View 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];

View 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>

View 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>

View 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}

View 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}

View 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;
}

View File

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { Reports } from './types.ts';
export const reports = writable<Reports>([]);

View 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'];

View 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>

View 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;
}

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

View 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>

View 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}

View 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}

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

View File

@ -0,0 +1,4 @@
import type { Teams } from './types.ts';
import { writable } from 'svelte/store';
export const teams = writable<Teams>([]);

View 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'];

View 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>

View 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>

View 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 />

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

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

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

View 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>

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

BIN
src/assets/img/skeleton.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/assets/img/steve.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/img/varo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View 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;
}

View 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>

View 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>

View 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)}
/>

View 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)}
/>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const confirmPopupState = atom<{ title: string; message: string; onConfirm: () => void } | null>(null);

View 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>

View File

@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string } | null>(null);

View 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>

View 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>

View 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>

View 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>

View 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&nbsp;&nbsp;</span>
<span class="inline-flex rounded-sm w-3 h-3" style="background-color: {$registeredPopupState?.teamColor}"></span>
<span>{$registeredPopupState?.team}</span>
<span>&nbsp;&nbsp;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>

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

View 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>

View File

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const rulesPopupState = atom<'open' | 'closed' | 'accepted'>('closed');
export const rulesPopupRead = atom(false);

View 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>

View File

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const teamPopupOpen = atom(false);
export const teamPopupName = atom<string | null>(null);

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