Compare commits
188 Commits
722026c938
...
main
Author | SHA1 | Date | |
---|---|---|---|
a6d910f56a | |||
fde50d21a6 | |||
8ea1750f1a | |||
5935b0d561 | |||
6e7c2eafca | |||
3c8dc30e43 | |||
8d8b1c52c0 | |||
1596fb605e | |||
7357ad9e88 | |||
3dd56bc471 | |||
8e60f83b6f | |||
0280e2a277 | |||
60f031aa7b | |||
e7bba22784 | |||
a0cc11860f | |||
ffc4dcf8f5 | |||
ccdf9b9bed | |||
48d3565008 | |||
1d8e99be8a | |||
eb0d0f8db3 | |||
8cb1e8bec5 | |||
9e282cf61b | |||
4dc8d9b646 | |||
672379c27b | |||
332089228e | |||
e30446598c | |||
a1965c62e2 | |||
4627d3de30 | |||
63cdf5c33d | |||
7872744ab0 | |||
95968148a6 | |||
abffa440a1 | |||
5e07f4d545 | |||
06751f0e27 | |||
b5e0dfad8c | |||
b823e198ab | |||
5ff950bcc0 | |||
f9de94db08 | |||
7ec1d8ab1d | |||
1ea07f7666 | |||
1e7915837a | |||
55798fd294 | |||
ceaf006dd5 | |||
aacd618d4f | |||
c89cbdd389 | |||
97f10da146 | |||
dc3a404a5b | |||
dc86dceb2f | |||
58d39921cb | |||
f74aa38bef | |||
0066736527 | |||
750d1b43d7 | |||
f461f6db77 | |||
df91278db0 | |||
bd33727aa6 | |||
676bfc23d8 | |||
0be3c31b51 | |||
b15b74c9b2 | |||
7bb0bd07ac | |||
a489b8cdd3 | |||
6635591788 | |||
45f8550604 | |||
865a8eee24 | |||
23b4e6249f | |||
bacc39dea0 | |||
e41c65e1fc | |||
d1b7fa58b4 | |||
a671989f02 | |||
204e2e915d | |||
17644b7952 | |||
a9b7577ec8 | |||
903cae13e2 | |||
065395590a | |||
0389ee4c3b | |||
937464d681 | |||
f930deaba8 | |||
6c7442e33c | |||
3f3f691c52 | |||
7e08cd06fc | |||
0bb02b4687 | |||
c6040f06dd | |||
b59354c2f9 | |||
c2c1660064 | |||
ac38540424 | |||
11db3a16ab | |||
89152bfaa9 | |||
0d5e68689c | |||
414247a891 | |||
aa91eaf82a | |||
18135a0816 | |||
dbe9810b90 | |||
4375796679 | |||
231b75c47e | |||
4206797862 | |||
f6f613b008 | |||
977905c390 | |||
6d9f3c41aa | |||
f74f1fe19e | |||
3310a82c29 | |||
e991da4db3 | |||
27c525d5bd | |||
b2d18b81a8 | |||
c98905e285 | |||
9f5fe25653 | |||
3762872e01 | |||
29d9765a81 | |||
8910a98489 | |||
fe6fadee39 | |||
366913c5b3 | |||
8ccff82fd3 | |||
b92e494ecb | |||
7878fef301 | |||
ca16ce0603 | |||
2a9869ca7d | |||
8a5eed787a | |||
9352083884 | |||
58bc475aec | |||
7599f233a8 | |||
6519a4071a | |||
1561681171 | |||
561e6683dd | |||
9ebea2a1e8 | |||
d089ba36fe | |||
372e91121d | |||
74c842272d | |||
9af8a50706 | |||
2538285632 | |||
7400b41670 | |||
fc6fc097e9 | |||
b932d88990 | |||
a024dfb626 | |||
dd19ff8c15 | |||
f6f9fafc64 | |||
d5ad9a7890 | |||
5a31406a1c | |||
9725cac44b | |||
26aaf8c677 | |||
971ca4bc75 | |||
4e16487d3d | |||
09da379812 | |||
235dfe3094 | |||
44454f445f | |||
cf90924672 | |||
63605e23b1 | |||
1f150bae06 | |||
b75620c892 | |||
f53dc28597 | |||
974757511d | |||
3f6913ef5f | |||
5aa429d7eb | |||
5fd7f715e7 | |||
18d45b1a81 | |||
c6a9eaa27a | |||
0ec9751f41 | |||
9cd78231c3 | |||
47867738f8 | |||
a872613f1e | |||
38906df545 | |||
05ddd05a5b | |||
dc21366f7a | |||
245d980b9a | |||
b862b7c24d | |||
5442d0b745 | |||
9af519d72f | |||
4a4135c31e | |||
241d6c031e | |||
c7a17d4481 | |||
73506fd81d | |||
9dc8c59271 | |||
475ccc8c99 | |||
9562cdeb95 | |||
981e1c3f9b | |||
81d97380ca | |||
72eeb59230 | |||
4aaf63c63f | |||
1f8cf66e90 | |||
444631f649 | |||
85597585da | |||
8a80b0a9e0 | |||
74e56d0ec8 | |||
6eb44cc33b | |||
56aa3c2673 | |||
9be57c1004 | |||
9ababd4847 | |||
b1f546ee94 | |||
b7177708a7 | |||
3713c7eaba | |||
61ea07d371 |
@ -3,3 +3,10 @@ ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
PUBLIC_START_DATE=2023-12-26T00:00:00+0200
|
||||
PUBLIC_BASE_PATH=
|
||||
|
||||
API_SECRET=
|
||||
|
||||
PUBLIC_SERVER_IP=example.com
|
||||
PUBLIC_TS_LINK=ts3server://example.com
|
||||
PUBLIC_DISCORD_LINK=https://example.com
|
||||
PUBLIC_PAYPAL_LINK=https://example.com
|
||||
|
@ -1,13 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'prettier'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
@ -4,6 +4,5 @@
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
@ -30,11 +30,13 @@ $ node -r dotenv/config build/index.js
|
||||
Configurations can be done with env variables
|
||||
|
||||
| Name | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `HOST` | Host the server should listen on |
|
||||
| `PORT` | Port the server should listen on |
|
||||
| `DATABASE_URI` | URI to the database as a connection string. Supported databases are [sqlite](https://www.sqlite.org/index.html) and [mariadb](https://mariadb.org/) |
|
||||
| `ADMIN_USER` | Name for the root admin user. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
|
||||
| `ADMIN_PASSWORD` | Password for the root admin user defined via `ADMIN_USER`. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
|
||||
| `REPORT_SECRET` | Secret which may be required (as `?secret=<secret>` query parameter) to create reports on the public endpoint. Isn't required to be in the request if this variable is empty |
|
||||
| `REPORTED_WEBHOOK` | URL to send POST request to when a report got finished |
|
||||
| `PUBLIC_BASE_PATH` | If running the website on a sub-path, set this variable to the path so that assets etc. can find the correct location |
|
||||
| `PUBLIC_START_DATE` | The start date when the event starts |
|
||||
|
38
eslint.config.mjs
Normal file
38
eslint.config.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
}
|
||||
);
|
5861
package-lock.json
generated
5861
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@ -9,45 +9,51 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/node": "^20.5.6",
|
||||
"@types/validator": "^13.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^3.6.3",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.30.0",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"sass": "^1.66.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.3",
|
||||
"svelte-heros-v2": "^0.9.3",
|
||||
"svelte-local-storage-store": "^0.6.0",
|
||||
"@fontsource/nunito": "^5.1.0",
|
||||
"@fontsource/roboto": "^5.1.0",
|
||||
"@sveltejs/adapter-node": "^5.2.9",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/validator": "^13.12.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^4.12.14",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.13.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.1",
|
||||
"prettier-plugin-svelte": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"publint": "^0.2.12",
|
||||
"sass": "^1.81.0",
|
||||
"skinview3d": "^3.1.0",
|
||||
"svelte": "^5.3.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"svelte-heros-v2": "^2.0.1",
|
||||
"svelte-multicssclass": "^2.1.1",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2",
|
||||
"vitest": "^0.34.1"
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.16.0",
|
||||
"vite": "^6.0.1",
|
||||
"vitest": "^2.1.6",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"mariadb": "^3.2.0",
|
||||
"sequelize": "^6.32.1",
|
||||
"sequelize-typescript": "^2.1.5",
|
||||
"sqlite3": "^5.1.6"
|
||||
"dotenv": "^16.4.5",
|
||||
"mariadb": "^3.3.2",
|
||||
"sequelize": "^6.37.4",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sqlite3": "^5.1.7"
|
||||
}
|
||||
}
|
||||
|
27
src/app.css
27
src/app.css
@ -1,3 +1,6 @@
|
||||
@import '@fontsource/nunito';
|
||||
@import '@fontsource/roboto';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@ -8,30 +11,6 @@
|
||||
src: url('/fonts/MinecraftRegular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-roboto scroll-smooth;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
// start date in milliseconds. if undefined, start will be Date.now
|
||||
export let start: number | undefined = undefined;
|
||||
// end date in milliseconds
|
||||
export let end: number;
|
||||
let { start, end }: { start?: number; end: number } = $props();
|
||||
|
||||
let title = `Spielstart ist am ${new Date(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;
|
||||
@ -17,7 +23,7 @@
|
||||
];
|
||||
}
|
||||
|
||||
let [days, hours, minutes, seconds] = getUntil();
|
||||
let [days, hours, minutes, seconds] = $state(getUntil());
|
||||
let intervalId = setInterval(() => {
|
||||
[days, hours, minutes, seconds] = getUntil();
|
||||
if (start) start += 1000;
|
||||
@ -26,28 +32,31 @@
|
||||
onDestroy(() => clearInterval(intervalId));
|
||||
</script>
|
||||
|
||||
<div class="grid grid-flow-col gap-5 text-center auto-cols-max">
|
||||
<div class="flex flex-col">
|
||||
<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 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{days};" />
|
||||
<span class="m-auto" style="--value:{days};"></span>
|
||||
</span>
|
||||
Tage
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{hours};" />
|
||||
<span class="m-auto" style="--value:{hours};"></span>
|
||||
</span>
|
||||
Stunden
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{minutes};" />
|
||||
<span class="m-auto" style="--value:{minutes};"></span>
|
||||
</span>
|
||||
Minuten
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}>
|
||||
<span class="countdown font-mono text-3xl sm:text-6xl">
|
||||
<span class="m-auto" style="--value:{seconds};" />
|
||||
<span class="m-auto" style="--value:{seconds};"></span>
|
||||
</span>
|
||||
Sekunden
|
||||
</div>
|
||||
|
9
src/lib/components/CustomIcons/Crosshairs.svelte
Normal file
9
src/lib/components/CustomIcons/Crosshairs.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { size = '24', fill = 'currentColor' } = $props();
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M256 0c17.7 0 32 14.3 32 32V42.4c93.7 13.9 167.7 88 181.6 181.6H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H469.6c-13.9 93.7-88 167.7-181.6 181.6V480c0 17.7-14.3 32-32 32s-32-14.3-32-32V469.6C130.3 455.7 56.3 381.7 42.4 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H42.4C56.3 130.3 130.3 56.3 224 42.4V32c0-17.7 14.3-32 32-32zM107.4 288c12.5 58.3 58.4 104.1 116.6 116.6V384c0-17.7 14.3-32 32-32s32 14.3 32 32v20.6c58.3-12.5 104.1-58.4 116.6-116.6H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h20.6C392.1 165.7 346.3 119.9 288 107.4V128c0 17.7-14.3 32-32 32s-32-14.3-32-32V107.4C165.7 119.9 119.9 165.7 107.4 224H128c17.7 0 32 14.3 32 32s-14.3 32-32 32H107.4zM256 224a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
|
||||
/></svg
|
||||
>
|
9
src/lib/components/CustomIcons/Skull.svelte
Normal file
9
src/lib/components/CustomIcons/Skull.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
let { size = '24', fill = 'currentColor' } = $props();
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M416 398.9c58.5-41.1 96-104.1 96-174.9C512 100.3 397.4 0 256 0S0 100.3 0 224c0 70.7 37.5 133.8 96 174.9c0 .4 0 .7 0 1.1v64c0 26.5 21.5 48 48 48h48V464c0-8.8 7.2-16 16-16s16 7.2 16 16v48h64V464c0-8.8 7.2-16 16-16s16 7.2 16 16v48h48c26.5 0 48-21.5 48-48V400c0-.4 0-.7 0-1.1zM96 256a64 64 0 1 1 128 0A64 64 0 1 1 96 256zm256-64a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"
|
||||
/></svg
|
||||
>
|
@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
// eslint-disable-next-line no-undef
|
||||
type T = $$Generic;
|
||||
|
||||
export let id: string | null = null;
|
||||
export let name: string | null = null;
|
||||
export let disabled = false;
|
||||
export let available: string[] | { [key: string]: T } = {};
|
||||
export let value: T[] = [];
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
disabled = false,
|
||||
available = {},
|
||||
value = $bindable([])
|
||||
}: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
available?: string[] | { [key: string]: any };
|
||||
value: any[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@ -15,7 +20,7 @@
|
||||
{name}
|
||||
class="select select-bordered select-xs"
|
||||
disabled={disabled || available.length === 0}
|
||||
on:change={(e) => {
|
||||
onchange={(e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
value.push(Object.values(available)[Object.keys(available).indexOf(e.target.value)]);
|
||||
@ -42,7 +47,7 @@
|
||||
<button
|
||||
{disabled}
|
||||
class:pointer-events-none={disabled}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
value.splice(i, 1);
|
||||
value = value;
|
||||
}}>✕</button
|
||||
|
@ -1,26 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { IconSolid } from 'svelte-heros-v2';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Eye, EyeSlash } from 'svelte-heros-v2';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let id: string | null = null;
|
||||
export let name: string | null = null;
|
||||
export let type = 'text';
|
||||
export let value: string | null = null;
|
||||
export let placeholder: string | null = null;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
|
||||
export let pickyWidth = true;
|
||||
|
||||
export let inputElement: HTMLInputElement | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function input(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
dispatch('input', e);
|
||||
}
|
||||
function click(e: Event) {
|
||||
dispatch('click', e);
|
||||
}
|
||||
let {
|
||||
label,
|
||||
notice,
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
value = $bindable(),
|
||||
placeholder,
|
||||
pattern,
|
||||
required = false,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
checked = $bindable(false),
|
||||
size = 'md',
|
||||
pickyWidth = true,
|
||||
containerClass = '',
|
||||
inputElement = $bindable(),
|
||||
oninput,
|
||||
onclick
|
||||
}: {
|
||||
label?: Snippet;
|
||||
notice?: Snippet;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
pattern?: RegExp;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
checked?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
pickyWidth?: boolean;
|
||||
containerClass?: string;
|
||||
inputElement?: HTMLInputElement;
|
||||
oninput?: (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => void;
|
||||
onclick?: (e: Event) => void;
|
||||
} = $props();
|
||||
|
||||
let initialType = type;
|
||||
|
||||
@ -33,7 +53,7 @@
|
||||
</script>
|
||||
|
||||
<!-- the cursor-not-allowed class must be set here because a disabled button does not respect the 'cursor' css property -->
|
||||
<div class={type === 'submit' && disabled ? 'cursor-not-allowed' : ''}>
|
||||
<div class={containerClass} class:cursor-not-allowed={type === 'submit' && disabled}>
|
||||
{#if type === 'submit'}
|
||||
<input
|
||||
class="btn"
|
||||
@ -46,15 +66,15 @@
|
||||
{disabled}
|
||||
bind:value
|
||||
bind:this={inputElement}
|
||||
on:input={input}
|
||||
on:click={click}
|
||||
{oninput}
|
||||
{onclick}
|
||||
/>
|
||||
{:else}
|
||||
<div>
|
||||
{#if $$slots.label}
|
||||
{#if label}
|
||||
<label class="label" for={id}>
|
||||
<span class="label-text">
|
||||
<slot name="label" />
|
||||
{@render label()}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
@ -78,50 +98,56 @@
|
||||
class:input-lg={type !== 'checkbox' && size === 'lg'}
|
||||
class:input-bordered={type !== 'checkbox'}
|
||||
class:pr-11={initialType === 'password'}
|
||||
class:!border-none,!text-inherit={disabled}
|
||||
{id}
|
||||
{name}
|
||||
{type}
|
||||
{value}
|
||||
{checked}
|
||||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
bind:this={inputElement}
|
||||
autocomplete="off"
|
||||
on:input={(e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
value = e.target?.value;
|
||||
input(e);
|
||||
onchange={() => {
|
||||
if (type === 'checkbox') {
|
||||
checked = !checked;
|
||||
}
|
||||
}}
|
||||
on:click={click}
|
||||
oninput={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
value = e.currentTarget.value;
|
||||
if (pattern && !pattern.test(value)) return;
|
||||
oninput?.(e);
|
||||
}}
|
||||
onpaste={(e) => {
|
||||
if (pattern && e.clipboardData && !pattern.test(e.clipboardData.getData('text'))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{onclick}
|
||||
/>
|
||||
{#if initialType === 'password'}
|
||||
<button
|
||||
class="absolute right-3"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
type = type === 'password' ? 'text' : 'password';
|
||||
}}
|
||||
>
|
||||
{#if type === 'password'}
|
||||
<IconSolid
|
||||
name="eye-slash-solid"
|
||||
width={passwordEyeSize[size]}
|
||||
height={passwordEyeSize[size]}
|
||||
/>
|
||||
<EyeSlash variation="solid" size={passwordEyeSize[size]} />
|
||||
{:else}
|
||||
<IconSolid
|
||||
name="eye-solid"
|
||||
width={passwordEyeSize[size]}
|
||||
height={passwordEyeSize[size]}
|
||||
/>
|
||||
<Eye variation="solid" size={passwordEyeSize[size]} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $$slots.notice}
|
||||
{#if notice}
|
||||
<label class="label" for={id}>
|
||||
<span class="label-text-alt"><slot name="notice" /></span>
|
||||
<span class="label-text-alt">
|
||||
{@render notice()}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
105
src/lib/components/Input/Search.svelte
Normal file
105
src/lib/components/Input/Search.svelte
Normal file
@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
id,
|
||||
value = $bindable(),
|
||||
suggestionRequired = false,
|
||||
emptyAllowed = false,
|
||||
searchSuggestionFunc = () => Promise.resolve([]),
|
||||
invalidMessage,
|
||||
size = 'md',
|
||||
label,
|
||||
required = false,
|
||||
onsubmit
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
suggestionRequired?: boolean;
|
||||
emptyAllowed?: boolean;
|
||||
searchSuggestionFunc?: (input: string) => Promise<{ name: string; value: string }[]>;
|
||||
invalidMessage?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
onsubmit?: (event: Event & { input: string; value: string }) => void;
|
||||
} = $props();
|
||||
|
||||
let elemValue = $state(value);
|
||||
let searchSuggestions: { name: string; value: string }[] = $state([]);
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div>
|
||||
{#if label}
|
||||
<label class="label" for={id}>
|
||||
<span class="label-text">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
<input
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="input input-bordered w-full"
|
||||
class:input-xs={size === 'xs'}
|
||||
class:input-sm={size === 'sm'}
|
||||
class:input-md={size === 'md'}
|
||||
class:input-lg={size === 'lg'}
|
||||
{id}
|
||||
{required}
|
||||
bind:value={elemValue}
|
||||
oninput={async (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
searchSuggestions = await searchSuggestionFunc(elemValue);
|
||||
const searchSuggestion = searchSuggestions.find((v) => v.name === elemValue);
|
||||
if (searchSuggestion !== undefined) {
|
||||
elemValue = searchSuggestion.name;
|
||||
value = searchSuggestion.value;
|
||||
searchSuggestions = [];
|
||||
(e.currentTarget || e.target).setCustomValidity('');
|
||||
onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
|
||||
} else if (elemValue === '' && emptyAllowed) {
|
||||
onsubmit?.(Object.assign(e, { input: '', value: '' }));
|
||||
} else {
|
||||
value = '';
|
||||
}
|
||||
}}
|
||||
oninvalid={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
if (invalidMessage) e.currentTarget.setCustomValidity(invalidMessage);
|
||||
}}
|
||||
onfocus={() => searchSuggestionFunc(elemValue).then((v) => (searchSuggestions = v))}
|
||||
pattern={suggestionRequired
|
||||
? `${value ? elemValue : 'a^' + (emptyAllowed ? '|$^' : '')}`
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if elemValue && searchSuggestions.length !== 0}
|
||||
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||
{#each searchSuggestions as searchSuggestion}
|
||||
<li class="w-full text-left">
|
||||
<button
|
||||
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title="{searchSuggestion.name} ({searchSuggestion.value})"
|
||||
onclick={(e) => {
|
||||
elemValue = searchSuggestion.name;
|
||||
value = searchSuggestion.value;
|
||||
searchSuggestions = [];
|
||||
onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
|
||||
}}>{searchSuggestion.name}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- close the search suggestions box when clicking outside -->
|
||||
{#if elemValue && searchSuggestions.length !== 0}
|
||||
<button
|
||||
aria-label=" "
|
||||
class="absolute top-0 left-0 z-10 w-full h-full cursor-default"
|
||||
onclick={() => (searchSuggestions = [])}
|
||||
></button>
|
||||
{/if}
|
@ -1,14 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let id: string | null = null;
|
||||
export let name: string | null = null;
|
||||
export let value: any | null = null;
|
||||
export let label: string | null = null;
|
||||
export let notice: string | null = null;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
|
||||
let {
|
||||
children,
|
||||
id,
|
||||
name,
|
||||
value = $bindable(),
|
||||
label,
|
||||
notice,
|
||||
required = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
pickyWidth = true,
|
||||
onChange
|
||||
}: {
|
||||
children: Snippet;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
label?: string;
|
||||
notice?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
pickyWidth?: boolean;
|
||||
onChange?: ({ value }: { value: any }) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@ -23,18 +40,21 @@
|
||||
</label>
|
||||
{/if}
|
||||
<select
|
||||
class="select select-bordered w-[100%] sm:max-w-[16rem]"
|
||||
class="select select-bordered w-[100%]"
|
||||
class:sm:max-w-[16rem]={pickyWidth}
|
||||
class:select-xs={size === 'xs'}
|
||||
class:select-sm={size === 'sm'}
|
||||
class:select-md={size === 'md'}
|
||||
class:select-lg={size === 'lg'}
|
||||
class:!border-none,!text-inherit={disabled}
|
||||
{id}
|
||||
{name}
|
||||
{required}
|
||||
{disabled}
|
||||
bind:value
|
||||
onchange={() => onChange && onChange({ value: value })}
|
||||
>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</select>
|
||||
{#if notice}
|
||||
<label class="label" for={id}>
|
||||
|
@ -1,18 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let id: string | null = null;
|
||||
export let name: string | null = null;
|
||||
export let value: string | null = null;
|
||||
export let label: string | null = null;
|
||||
export let notice: string | null = null;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let readonly = false;
|
||||
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
|
||||
export let rows = 2;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
value = $bindable(),
|
||||
label,
|
||||
notice,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
size = 'md',
|
||||
rows = 2
|
||||
}: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
label?: string;
|
||||
notice?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
rows?: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@ -27,12 +36,12 @@
|
||||
</label>
|
||||
{/if}
|
||||
<textarea
|
||||
class="textarea w-full"
|
||||
class="textarea textarea-bordered w-full"
|
||||
class:textarea-xs={size === 'xs'}
|
||||
class:textarea-sm={size === 'sm'}
|
||||
class:textarea-md={size === 'md'}
|
||||
class:textarea-lg={size === 'lg'}
|
||||
class:textarea-bordered={!readonly}
|
||||
class:!border-none,!text-inherit={disabled}
|
||||
{id}
|
||||
{name}
|
||||
{required}
|
||||
@ -40,8 +49,7 @@
|
||||
{readonly}
|
||||
{rows}
|
||||
bind:value
|
||||
on:click={(e) => dispatch('click', e)}
|
||||
/>
|
||||
></textarea>
|
||||
{#if notice}
|
||||
<label class="label" for={id}>
|
||||
<span class="label-text-alt">{notice}</span>
|
||||
|
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet, tick } from 'svelte';
|
||||
|
||||
let { children, onUpdate }: { children: Snippet; onUpdate: () => Promise<any> } = $props();
|
||||
|
||||
let bodyElem: HTMLTableSectionElement;
|
||||
let intersectionElem: HTMLElement;
|
||||
|
||||
async function getIntersectionElement(): Promise<HTMLElement> {
|
||||
if (!bodyElem.lastElementChild) {
|
||||
await new Promise<void>((resolve) => {
|
||||
new MutationObserver((_, observer) => {
|
||||
if (!bodyElem.lastElementChild) return;
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return bodyElem.rows.item(bodyElem.rows.length - 15)! || bodyElem.lastElementChild!;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await onUpdate();
|
||||
await tick();
|
||||
|
||||
if (!bodyElem) return;
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
async (entries, observer) => {
|
||||
if (entries.filter((e) => e.isIntersecting).length === 0 || !entries) return;
|
||||
observer.unobserve(intersectionElem);
|
||||
|
||||
const rows = bodyElem.rows.length;
|
||||
|
||||
await onUpdate();
|
||||
await tick();
|
||||
|
||||
if (rows === bodyElem.rows.length) return;
|
||||
observer.observe((intersectionElem = await getIntersectionElement()));
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
);
|
||||
|
||||
new MutationObserver(async (entries) => {
|
||||
if (!entries) {
|
||||
return;
|
||||
} else if (
|
||||
entries.findIndex((e) => e.addedNodes.length > 0 || e.removedNodes.length > 0) == -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intersectionElem) intersectionObserver.unobserve(intersectionElem);
|
||||
intersectionObserver.observe((intersectionElem = await getIntersectionElement()));
|
||||
}).observe(bodyElem, { childList: true });
|
||||
|
||||
intersectionObserver.observe((intersectionElem = await getIntersectionElement()));
|
||||
});
|
||||
</script>
|
||||
|
||||
<tbody bind:this={bodyElem}>
|
||||
{@render children()}
|
||||
</tbody>
|
40
src/lib/components/Table/SortableTh.svelte
Normal file
40
src/lib/components/Table/SortableTh.svelte
Normal file
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, type Snippet } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { ChevronDown, ChevronUp } from 'svelte-heros-v2';
|
||||
|
||||
let { children, onSort }: { children: Snippet; onSort: ({ asc }: { asc: boolean }) => void } =
|
||||
$props();
|
||||
|
||||
let id = crypto.randomUUID();
|
||||
|
||||
let asc = $state(false);
|
||||
|
||||
let { ascHeader } = getContext('sortableTr') as { ascHeader: Writable<null | string> };
|
||||
ascHeader.subscribe((v) => {
|
||||
if (v !== id) asc = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if ($ascHeader === id) $ascHeader = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<th>
|
||||
<button
|
||||
class="flex flex-center"
|
||||
onclick={() => {
|
||||
onSort({ asc: (asc = !asc) });
|
||||
$ascHeader = id;
|
||||
}}
|
||||
>
|
||||
<span class="mr-1">
|
||||
{@render children()}
|
||||
</span>
|
||||
{#if $ascHeader === id && asc}
|
||||
<ChevronUp variation="solid" />
|
||||
{:else}
|
||||
<ChevronDown variation="solid" />
|
||||
{/if}
|
||||
</button>
|
||||
</th>
|
14
src/lib/components/Table/SortableTr.svelte
Normal file
14
src/lib/components/Table/SortableTr.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let { children, ...restProps }: { children: Snippet; [x: string]: unknown } = $props();
|
||||
|
||||
setContext('sortableTr', {
|
||||
ascHeader: writable(null)
|
||||
});
|
||||
</script>
|
||||
|
||||
<tr {...restProps}>
|
||||
{@render children()}
|
||||
</tr>
|
@ -1,17 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { IconOutline } from 'svelte-heros-v2';
|
||||
import { ExclamationCircle } from 'svelte-heros-v2';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let timeout = 2000;
|
||||
export let show = false;
|
||||
let { children, timeout = 2000, show = false } = $props();
|
||||
|
||||
export function reset() {
|
||||
progressValue = 1;
|
||||
}
|
||||
|
||||
let progressValue = 100;
|
||||
let intervalClear: ReturnType<typeof setInterval> | undefined;
|
||||
let progressValue = $state(100);
|
||||
let intervalClear: ReturnType<typeof setInterval> | undefined = $state();
|
||||
|
||||
function startTimout() {
|
||||
intervalClear = setInterval(() => {
|
||||
@ -23,10 +18,11 @@
|
||||
}, timeout / 100);
|
||||
}
|
||||
|
||||
$: if (show) {
|
||||
$effect(() => {
|
||||
if (!show) return;
|
||||
progressValue = 0;
|
||||
startTimout();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => clearInterval(intervalClear));
|
||||
</script>
|
||||
@ -36,23 +32,23 @@
|
||||
in:fly={{ x: 0, duration: 200 }}
|
||||
out:fly={{ x: 400, duration: 400 }}
|
||||
class="toast"
|
||||
on:mouseenter={() => {
|
||||
onmouseenter={() => {
|
||||
clearInterval(intervalClear);
|
||||
progressValue = 1;
|
||||
}}
|
||||
on:mouseleave={startTimout}
|
||||
onmouseleave={startTimout}
|
||||
role="alert"
|
||||
>
|
||||
<div class="alert alert-error border-none relative text-gray-900 overflow-hidden">
|
||||
<div class="flex gap-2 z-10">
|
||||
<IconOutline name="exclamation-circle-outline" />
|
||||
<slot />
|
||||
<ExclamationCircle />
|
||||
{@render children()}
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]"
|
||||
value={progressValue}
|
||||
max="100"
|
||||
/>
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -3,56 +3,3 @@ export async function buttonTriggeredRequest<T>(e: MouseEvent, promise: Promise<
|
||||
await promise;
|
||||
(e.target as HTMLButtonElement).disabled = false;
|
||||
}
|
||||
|
||||
export function resizeTableColumn(event: MouseEvent, dragOffset: number) {
|
||||
const element = event.target as HTMLTableCellElement;
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
const posX = event.clientX - rect.left;
|
||||
const offset = rect.width - event.clientX;
|
||||
if (posX <= dragOffset || posX >= rect.width - dragOffset) {
|
||||
// do not resize if resize request is on the table left or right
|
||||
if (
|
||||
(posX <= dragOffset && !element.previousElementSibling) ||
|
||||
(posX >= rect.width - dragOffset && !element.nextElementSibling)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = element.parentElement!.parentElement!.parentElement as HTMLTableElement;
|
||||
let resizeRow: HTMLTableRowElement;
|
||||
if (table.tBodies[0].rows[0].hasAttribute('resize-row')) {
|
||||
resizeRow = table.tBodies[0].rows[0];
|
||||
} else {
|
||||
resizeRow = table.tBodies[0].insertRow(0);
|
||||
resizeRow.setAttribute('resize-row', '');
|
||||
resizeRow.style.height = '0';
|
||||
resizeRow.style.border = '0';
|
||||
resizeRow.style.overflow = 'hidden';
|
||||
for (let i = 0; i < table.rows[0].cells.length; i++) {
|
||||
const cell = resizeRow.insertCell();
|
||||
cell.style.padding = '0';
|
||||
}
|
||||
|
||||
// insert an additional to keep the zebra in place pattern which might be applied
|
||||
const zebraGhostRow = table.tBodies[0].insertRow(1);
|
||||
zebraGhostRow.hidden = true;
|
||||
}
|
||||
|
||||
const resizeElement =
|
||||
resizeRow.cells[element.cellIndex - ((posX <= dragOffset) as unknown as number)];
|
||||
|
||||
// eslint-disable-next-line svelte/no-inner-declarations,no-inner-declarations
|
||||
function resize(e: MouseEvent) {
|
||||
document.body.style.cursor = 'col-resize';
|
||||
resizeElement.style.width = `${offset + e.clientX}px`;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', resize);
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
document.removeEventListener('mousemove', resize);
|
||||
document.body.style.cursor = 'initial';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
18
src/lib/context.ts
Normal file
18
src/lib/context.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
export type PopupModalContextArgs = {
|
||||
title: string;
|
||||
text?: string;
|
||||
actions?: { text: string; action?: (modal: Event) => void }[];
|
||||
onClose?: () => void;
|
||||
};
|
||||
export function getPopupModalShowFn(): ({
|
||||
title,
|
||||
text,
|
||||
actions,
|
||||
onClose
|
||||
}: PopupModalContextArgs) => void {
|
||||
const { set }: { set: ({ title, text, actions, onClose }: PopupModalContextArgs) => void } =
|
||||
getContext('globalPopupModal');
|
||||
return set;
|
||||
}
|
1
src/lib/extra-icons/globe.svg
Normal file
1
src/lib/extra-icons/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
@ -1,10 +1,10 @@
|
||||
export class Permissions {
|
||||
static readonly AdminRead = 2;
|
||||
static readonly AdminWrite = 4;
|
||||
static readonly UserRead = 8;
|
||||
static readonly UserWrite = 16;
|
||||
static readonly ReportRead = 32;
|
||||
static readonly ReportWrite = 64;
|
||||
static readonly Admin = 2 << 0;
|
||||
static readonly Users = 2 << 1;
|
||||
static readonly Reports = 2 << 2;
|
||||
static readonly Feedback = 2 << 3;
|
||||
static readonly Settings = 2 << 4;
|
||||
static readonly Tools = 2 << 5;
|
||||
|
||||
readonly value: number;
|
||||
|
||||
@ -28,32 +28,33 @@ export class Permissions {
|
||||
|
||||
static allPermissions(): number[] {
|
||||
return [
|
||||
Permissions.AdminRead,
|
||||
Permissions.AdminWrite,
|
||||
Permissions.UserRead,
|
||||
Permissions.UserWrite,
|
||||
Permissions.ReportRead,
|
||||
Permissions.ReportWrite
|
||||
Permissions.Admin,
|
||||
Permissions.Users,
|
||||
Permissions.Reports,
|
||||
Permissions.Feedback,
|
||||
Permissions.Settings,
|
||||
Permissions.Tools
|
||||
];
|
||||
}
|
||||
|
||||
adminRead(): boolean {
|
||||
return (this.value & Permissions.AdminRead) != 0;
|
||||
admin(): boolean {
|
||||
return (this.value & Permissions.Admin) != 0;
|
||||
}
|
||||
adminWrite(): boolean {
|
||||
return (this.value & Permissions.AdminWrite) != 0;
|
||||
|
||||
users(): boolean {
|
||||
return (this.value & Permissions.Users) != 0;
|
||||
}
|
||||
userRead(): boolean {
|
||||
return (this.value & Permissions.UserRead) != 0;
|
||||
reports(): boolean {
|
||||
return (this.value & Permissions.Reports) != 0;
|
||||
}
|
||||
userWrite(): boolean {
|
||||
return (this.value & Permissions.UserWrite) != 0;
|
||||
feedback(): boolean {
|
||||
return (this.value & Permissions.Reports) != 0;
|
||||
}
|
||||
reportRead(): boolean {
|
||||
return (this.value & Permissions.ReportRead) != 0;
|
||||
settings(): boolean {
|
||||
return (this.value & Permissions.Reports) != 0;
|
||||
}
|
||||
reportWrite(): boolean {
|
||||
return (this.value & Permissions.ReportWrite) != 0;
|
||||
tools(): boolean {
|
||||
return (this.value & Permissions.Tools) != 0;
|
||||
}
|
||||
|
||||
asArray(): number[] {
|
||||
|
119
src/lib/rules.ts
119
src/lib/rules.ts
@ -1,8 +1,112 @@
|
||||
export const rules = {
|
||||
export const rulesShort = {
|
||||
header: `
|
||||
Das Lesen der Regeln ist für alle Teilnehmer verpflichtend. Die Regeln sollen für einen reibungslosen und
|
||||
strukturierte Ablauf des Projekts sorgen, weshalb das Lesen der Regeln ein essenzieller Bestandteil für das Gelingen
|
||||
von CraftAttack 6 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
|
||||
von CraftAttack 7 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
|
||||
gehen sie nicht zu weit ins Detail und deuten teils nur umfangreiche Themengebiete an. Entscheidungen werden, wenn
|
||||
von Spielern angeregt, dann durch die Administratoren getroffen, die sich an den Regeln orientieren.
|
||||
`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Respektvoller Umgang',
|
||||
content: `
|
||||
Oberste Priorität hat der respektvolle und tolerante Umgang der Spieler untereinander. Der Spielspaß, der
|
||||
offene Umgang miteinander und die Interaktion aller steht im Vordergrund, weshalb Drohungen, Belästigungen
|
||||
oder sonstige gegenüber anderen Spielern respektlose Aktivitäten strengstens verboten sind und auch hart
|
||||
geahndet werden.`
|
||||
},
|
||||
{
|
||||
title: 'Einschränkungen von Minecraft-Namen, Skins, Chat-Nachrichten, Links, etc.',
|
||||
content: `
|
||||
Selbstverständlich sind sämtliche Inhalte (Minecraft-Namen, Skins, Chat-Nachrichten, Links, etc.) mit
|
||||
sexistischen, diskriminierenden, rassistischen, pornographischen oder illegalen Inhalten nicht erlaubt.
|
||||
Außerdem ist es nicht gestattet, den Chat mit Nachrichten jeglicher Art vollzuspammen. Des Weiteren sollte
|
||||
der MC-Name des Spielers, der bei der Anmeldung angegeben wird, bis zum Ende des Projekts nicht geändert
|
||||
werden. Das Nutzen bzw. Anmelden von Zweitaccounts ist nicht gestattet.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Clientmodifikationen',
|
||||
content: `
|
||||
Jegliche Clientmodifications, die deutliche Vorteile gegenüber anderen Spielern erbringen, sind nicht
|
||||
gestattet.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Redstone-Bauten und überdimensionierte Villager-Baukomplexe',
|
||||
content: `
|
||||
Das Erbauen und Betreiben lag-erzeugender Maschinen, Farmen (Zero-Tick-Farmen etc.) oder andere Bauten, die
|
||||
den Spielfluss stören könnten, ist verboten.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Verkauf von Items',
|
||||
content: `
|
||||
Das Verkaufen von Items ist allgemein jedem Spieler überall gestattet. Jedoch bietet es sich an und ist
|
||||
wünschenswert, die Shops aller Spieler in einem Shoppingdistrict beim Spawn gemeinsam anzusiedeln, um die
|
||||
Interaktion zu fördern. Ein angemessener Abstand der privaten Strukturen vom Shoppingdisrict ist
|
||||
einzuhalten.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Abstecken von Gebieten und Grundstücken',
|
||||
content: `
|
||||
Das Abstecken bestimmter Gebiete ist grundsätzlich erlaubt, jedoch sind unangemessen große Grundstücke
|
||||
untersagt. Das maximale Maß ist im Einzelfall zu entscheiden. Die Grenzen bereits abgesteckter Grundstücke
|
||||
sind unveränderlich.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Verhalten gegenüber anderen Spielern',
|
||||
content: `
|
||||
Das Töten, und Beklauen von Spielern ist verboten. Ebenso ist es nicht erlaubt, andere Bauten zu zerstören
|
||||
(Griefing). Ein gewisser Toleranzspielraum besteht, der im Einzelfall zu bewerten ist.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Rolle der Administratoren',
|
||||
content: `
|
||||
Allgemein liegt es in der Hand der Administratoren einzelne Situation zu bewerten, Strafen zu verhängen und
|
||||
Entscheidungen zu treffen. Den Entscheidungen und Anweisungen der Administratoren ist stets Folge zu
|
||||
leisten. Allgemein gilt immer der Grundsatz, dass ein Eingriff der Administratoren nur dann erfolgt, wenn
|
||||
dies die Spieler auch fordern. Solange beide Parteien zufrieden sind und sich niemand beschwert, passiert
|
||||
natürlich auch nichts.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Kontakt zum Administratoren-Team',
|
||||
content: `
|
||||
Jedem Teilnehmer ist es möglich sich an den Support/das Administratoren-Team zu wenden. Zu den
|
||||
Administratoren gehören die Spieler, die auf dem Server mit einem Admin-Tag versehen sind. Zwei von diesen
|
||||
sind außerdem Administrator der WhatsApp-Gruppe. Eine Kontaktaufnahme ist direkt auf dem Server im Chat
|
||||
oder auf dem Teamspeak: „mhsl.eu“ möglich. Außerdem können sie über WhatsApp angeschrieben werden, wenn
|
||||
sich z.B. gerade kein Administrator auf dem Server befindet oder bei anderen Rückfragen. Bei
|
||||
Unzufriedenheit, Meldung eines Regelverstoßen, Anregungen oder Fragen steht das Administratoren-Team allen
|
||||
Spielern jederzeit zu Verfügung.
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Konfliktlösung und mögliche Konsequenzen',
|
||||
content: `
|
||||
Konflikte sollen grundlegend zuerst auf einer Ebene zwischen den Spielern geschlichtet werden, bevor ein
|
||||
Administrator kontaktiert wird. Jeder Regelverstoß zieht unterschiedliche Folgen nach sich, die von
|
||||
Ermahnungen, über Tagesbänne bis zum permanenten Bann führen können. Diese möglichen Konsequenzen sind von
|
||||
allen Teilnehmern zu akzeptieren.
|
||||
`
|
||||
}
|
||||
],
|
||||
footer: `
|
||||
Alle aufgeführten Regeln und die damit in Verbindung stehende Angaben erfolgen ohne Gewähr auf Vollständigkeit,
|
||||
Richtigkeit und Aktualität. Das Durchsetzen der Regeln liegt im Ermessen der Administratoren, die vorher in
|
||||
Absprache mit dem Geschädigten eine der Situation angemessene Maßnahmen getroffen haben.
|
||||
`
|
||||
};
|
||||
|
||||
export const rulesLong = {
|
||||
header: `
|
||||
Das Lesen der Regeln ist für alle Teilnehmer verpflichtend. Die Regeln sollen für einen reibungslosen und
|
||||
strukturierte Ablauf des Projekts sorgen, weshalb das Lesen der Regeln ein essenzieller Bestandteil für das Gelingen
|
||||
von CraftAttack 7 ist. Die Regeln sind wörtlich zu verstehen und sind Grundlage für das Projekt. Zur Vereinfachung
|
||||
gehen sie nicht zu weit ins Detail und deuten teils nur umfangreiche Themengebiete an. Entscheidungen werden, wenn
|
||||
von Spielern angeregt, dann durch die Administratoren getroffen, die sich an den Regeln orientieren.
|
||||
`,
|
||||
@ -39,7 +143,7 @@ export const rules = {
|
||||
`
|
||||
},
|
||||
{
|
||||
title: 'Redstone bauten und überdimensionierte Villager-Baukomplexe',
|
||||
title: 'Redstone-Bauten und überdimensionierte Villager-Baukomplexe',
|
||||
content: `
|
||||
Das Erbauen und Betreiben lag-erzeugender Maschinen, Farmen (Zero-Tick-Farmen etc.) oder andere Bauten, die
|
||||
den Spielfluss stören könnten, ist verboten. Im Zweifelsfall ist eine Anfrage bei den Administratoren
|
||||
@ -96,11 +200,10 @@ export const rules = {
|
||||
diese als Administrator fungieren. Im normalen Spielbetrieb sind sie normale Mitspieler ohne
|
||||
spielentscheidende Sonderrechte. So ist es nicht ihre Aufgabe überall nach dem Rechten zu sehen, sondern
|
||||
Ansprechpartner zu sein, um dann nach der Vorlegung eines Problems durch einen Geschädigten die
|
||||
Administratorenrolle einzunehmen und dementsprechend zu handeln. In dem Feld ist einzutragen, wobei die
|
||||
Regeln trotzdem bis zum Ende gelesen werden müssen. Allgemein gilt immer der Grundsatz, dass ein Eingriff
|
||||
der Administratoren nur dann erfolgt, wenn dies die Spieler auch fordern. Solange beide Parteien zufrieden
|
||||
sind, passiert natürlich auch nichts. Wenn also beispielsweise zwei Spieler ein bewusstes pvp-Duell
|
||||
starten, zieht das logischer Weise keine Konsequenzen nach sich.
|
||||
Administratorenrolle einzunehmen und dementsprechend zu handeln. Allgemein gilt immer der Grundsatz, dass
|
||||
ein Eingriff der Administratoren nur dann erfolgt, wenn dies die Spieler auch fordern. Solange beide
|
||||
Parteien zufrieden sind, passiert natürlich auch nichts. Wenn also beispielsweise zwei Spieler ein
|
||||
bewusstes pvp-Duell starten, zieht das logischer Weise keine Konsequenzen nach sich.
|
||||
`
|
||||
},
|
||||
{
|
||||
|
@ -24,16 +24,16 @@ export class User extends Model {
|
||||
@Column({ type: DataTypes.DATE, allowNull: false })
|
||||
declare birthday: Date;
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare telephone: string;
|
||||
declare telephone: string | null;
|
||||
@Column({ type: DataTypes.STRING, allowNull: false })
|
||||
declare username: string;
|
||||
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'cracked'), allowNull: false })
|
||||
declare playertype: 'java' | 'bedrock' | 'cracked';
|
||||
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'noauth'), allowNull: false })
|
||||
declare playertype: 'java' | 'bedrock' | 'noauth';
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare password: string;
|
||||
@Column({ type: DataTypes.UUIDV4 })
|
||||
declare password: string | null;
|
||||
@Column({ type: DataTypes.UUID, unique: true })
|
||||
@Index
|
||||
declare uuid: string;
|
||||
declare uuid: string | null;
|
||||
}
|
||||
|
||||
@Table({ modelName: 'report', underscored: true })
|
||||
@ -43,32 +43,91 @@ export class Report extends Model {
|
||||
declare url_hash: string;
|
||||
@Column({ type: DataTypes.STRING, allowNull: false })
|
||||
declare subject: string;
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare body: string;
|
||||
@Column({ type: DataTypes.TEXT })
|
||||
declare body: string | null;
|
||||
@Column({ type: DataTypes.BOOLEAN, allowNull: false })
|
||||
declare draft: boolean;
|
||||
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'cracked'), allowNull: false })
|
||||
@Column({ type: DataTypes.ENUM('none', 'review', 'reviewed'), allowNull: false })
|
||||
declare status: 'none' | 'review' | 'reviewed';
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare notice: string;
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare statement: string;
|
||||
declare notice: string | null;
|
||||
@Column({ type: DataTypes.TEXT })
|
||||
declare statement: string | null;
|
||||
@Column({ type: DataTypes.DATE })
|
||||
declare striked_at: Date | null;
|
||||
@Column({ type: DataTypes.INTEGER, allowNull: false })
|
||||
@ForeignKey(() => User)
|
||||
declare reporter_id: number;
|
||||
@Column({ type: DataTypes.INTEGER, allowNull: false })
|
||||
@Column({ type: DataTypes.INTEGER })
|
||||
@ForeignKey(() => User)
|
||||
declare reported_id: number;
|
||||
declare reported_id: number | null;
|
||||
@Column({ type: DataTypes.INTEGER })
|
||||
@ForeignKey(() => Admin)
|
||||
declare auditor_id: number;
|
||||
declare auditor_id: number | null;
|
||||
@Column({ type: DataTypes.INTEGER })
|
||||
@ForeignKey(() => StrikeReason)
|
||||
declare strike_reason_id: number | null;
|
||||
|
||||
@BelongsTo(() => User, 'reporter_id')
|
||||
declare reporter: User;
|
||||
@BelongsTo(() => User, 'reported_id')
|
||||
declare reported: User;
|
||||
@BelongsTo(() => Admin, 'auditor_id')
|
||||
declare auditor: Admin;
|
||||
@BelongsTo(() => User, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: 'reporter_id'
|
||||
})
|
||||
declare reporter: User | null;
|
||||
@BelongsTo(() => User, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: 'reported_id'
|
||||
})
|
||||
declare reported: User | null;
|
||||
@BelongsTo(() => Admin, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: 'auditor_id'
|
||||
})
|
||||
declare auditor: Admin | null;
|
||||
@BelongsTo(() => StrikeReason, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: 'strike_reason_id'
|
||||
})
|
||||
declare strike_reason: StrikeReason | null;
|
||||
}
|
||||
|
||||
@Table({ modelName: 'strike_reason', underscored: true, createdAt: false, updatedAt: false })
|
||||
export class StrikeReason extends Model {
|
||||
@Column({ type: DataTypes.INTEGER, allowNull: false })
|
||||
declare weight: number;
|
||||
@Column({ type: DataTypes.STRING, allowNull: false })
|
||||
declare name: string;
|
||||
}
|
||||
|
||||
@Table({ modelName: 'strike_punishment', underscored: true, createdAt: false, updatedAt: false })
|
||||
export class StrikePunishment extends Model {
|
||||
@Column({ type: DataTypes.INTEGER, allowNull: false })
|
||||
declare weight: number;
|
||||
@Column({ type: DataTypes.ENUM('ban', 'outlawed'), allowNull: false })
|
||||
declare type: 'ban' | 'outlawed';
|
||||
@Column({ type: DataTypes.INTEGER, allowNull: false })
|
||||
declare punishment_in_seconds: number;
|
||||
}
|
||||
|
||||
@Table({ modelName: 'feedback', underscored: true })
|
||||
export class Feedback extends Model {
|
||||
@Column({ type: DataTypes.STRING, allowNull: false })
|
||||
declare event: string;
|
||||
@Column({ type: DataTypes.STRING })
|
||||
declare title: string | null;
|
||||
@Column({ type: DataTypes.TEXT })
|
||||
declare content: string | null;
|
||||
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
|
||||
@Index
|
||||
declare url_hash: string;
|
||||
@Column({ type: DataTypes.INTEGER })
|
||||
@ForeignKey(() => User)
|
||||
declare user_id: number | null;
|
||||
|
||||
@BelongsTo(() => User, {
|
||||
onDelete: 'CASCADE',
|
||||
foreignKey: 'user_id'
|
||||
})
|
||||
declare user: User | null;
|
||||
}
|
||||
|
||||
@Table({ modelName: 'admin', underscored: true })
|
||||
@ -103,8 +162,23 @@ export class Admin extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
@Table({ modelName: 'settings', underscored: true })
|
||||
export class Settings extends Model {
|
||||
@Column({ type: DataTypes.STRING, allowNull: false, unique: true })
|
||||
declare key: string;
|
||||
@Column({
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
get(this: Settings): any {
|
||||
const value = this.getDataValue('value');
|
||||
return value != null ? JSON.parse(value) : null;
|
||||
}
|
||||
})
|
||||
declare value: string;
|
||||
}
|
||||
|
||||
export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, {
|
||||
// only log sql queries in dev mode
|
||||
logging: dev ? console.log : false,
|
||||
models: [User, Report, Admin]
|
||||
models: [User, Report, StrikeReason, StrikePunishment, Feedback, Admin, Settings]
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getBedrockUuid, getCrackedUuid, getJavaUuid } from '$lib/server/minecraft';
|
||||
import { getBedrockUuid, getJavaUuid, getNoAuthUuid } from '$lib/server/minecraft';
|
||||
|
||||
describe('java username', () => {
|
||||
test('is valid', async () => {
|
||||
@ -19,9 +19,9 @@ describe('bedrock username', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cracked username', () => {
|
||||
describe('noauth username', () => {
|
||||
// every username can be converted to an uuid so every user id automatically valid
|
||||
test('is valid', () => {
|
||||
expect(getCrackedUuid('bytedream')).toBe('88de3863-bf47-30f9-a7f4-ab6134feb49a');
|
||||
expect(getNoAuthUuid('bytedream')).toBe('88de3863-bf47-30f9-a7f4-ab6134feb49a');
|
||||
});
|
||||
});
|
||||
|
@ -1,17 +1,27 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export class UserNotFoundError extends Error {
|
||||
readonly username: string;
|
||||
|
||||
constructor(username: string) {
|
||||
super(`Ein Spieler mit dem Namen '${username}' konnte nicht gefunden werden`);
|
||||
super(`couldn't find a player with the username '${username}'`);
|
||||
this.username = username;
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {}
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
export async function getJavaUuid(username: string): Promise<string> {
|
||||
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
|
||||
if (!response.ok) {
|
||||
if (response.status == 429) {
|
||||
throw new RateLimitError();
|
||||
}
|
||||
throw response.status < 500
|
||||
? new UserNotFoundError(username)
|
||||
: new Error(`mojang server error (${response.status}): ${await response.text()}`);
|
||||
: new ApiError(`mojang server error (${response.status}): ${await response.text()}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
const id: string = json['id'];
|
||||
@ -24,12 +34,13 @@ export async function getBedrockUuid(username: string): Promise<string> {
|
||||
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
|
||||
const initialPageContent = await initialPageResponse.text();
|
||||
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
|
||||
if (token === undefined) throw new Error("couldn't grab token from xuid converter website");
|
||||
if (token === undefined) throw new ApiError("couldn't grab token from xuid converter website");
|
||||
|
||||
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
|
||||
if (cookies === undefined)
|
||||
throw new Error("couldn't get response cookies from xuid converter website");
|
||||
else if (cookies.length < 11) throw new Error('xuid converter website sent unexpected cookies');
|
||||
throw new ApiError("couldn't get response cookies from xuid converter website");
|
||||
else if (cookies.length < 11)
|
||||
throw new ApiError('xuid converter website sent unexpected cookies');
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.set('_token', token);
|
||||
@ -55,7 +66,7 @@ export async function getBedrockUuid(username: string): Promise<string> {
|
||||
'Sec-Fetch-User': '?1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
@ -68,7 +79,7 @@ export async function getBedrockUuid(username: string): Promise<string> {
|
||||
}
|
||||
|
||||
// https://gist.github.com/yushijinhun/69f68397c5bb5bee76e80d192295f6e0
|
||||
export function getCrackedUuid(username: string): string {
|
||||
export function getNoAuthUuid(username: string): string {
|
||||
const data = createHash('md5').update(`OfflinePlayer:${username}`).digest('binary').split('');
|
||||
data[6] = String.fromCharCode((data[6].charCodeAt(0) & 0x0f) | 0x30);
|
||||
data[8] = String.fromCharCode((data[8].charCodeAt(0) & 0x3f) | 0x80);
|
||||
|
15
src/lib/server/webhook.ts
Normal file
15
src/lib/server/webhook.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export async function webhookUserReported(endpoint: string, uuid: string) {
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: uuid
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
throw (e as { message: string }).message;
|
||||
}
|
||||
}
|
@ -1,8 +1,17 @@
|
||||
import { persisted } from 'svelte-local-storage-store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const playAudio: Writable<boolean> = persisted('playAudio', false);
|
||||
|
||||
export const errorMessage: Writable<string | null> = (() => {
|
||||
const store: Writable<string | null> = writable(null);
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
set: (value) => {
|
||||
if (value != null) store.set(null);
|
||||
store.set(value);
|
||||
},
|
||||
update: store.update
|
||||
};
|
||||
})();
|
||||
export const reportCount: Writable<number> = writable(0);
|
||||
export const feedbackCount: Writable<number> = writable(0);
|
||||
export const adminCount: Writable<number> = writable(0);
|
||||
|
18
src/lib/utils.ts
Normal file
18
src/lib/utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export async function usernameSuggestions(
|
||||
username: string
|
||||
): Promise<{ name: string; value: string }[]> {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
limit: 6,
|
||||
search: username,
|
||||
slim: true
|
||||
})
|
||||
});
|
||||
const json: { username: string; uuid: string }[] = await response.json();
|
||||
return json.map((v) => {
|
||||
return { name: v.username, value: v.uuid };
|
||||
});
|
||||
}
|
@ -2,138 +2,139 @@
|
||||
import '../app.css';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { goto } from '$app/navigation';
|
||||
import Settings from './Settings.svelte';
|
||||
import { playAudio } from '$lib/stores';
|
||||
import { page } from '$app/stores';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { setContext, tick } from 'svelte';
|
||||
import type { PopupModalContextArgs } from '$lib/context';
|
||||
|
||||
let navPaths = [
|
||||
let { children } = $props();
|
||||
|
||||
let navPaths = $state([
|
||||
{
|
||||
name: 'Startseite',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-home.png`,
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-home.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/`,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Registrieren',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-register.png`,
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-register.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/register`,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Regeln',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-rules.png`,
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-rules.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/rules`,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'FAQ',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-faq.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/faq`,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Feedback & Kontakt',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-feedback.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/feedback`,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-team.webp`,
|
||||
href: `${env.PUBLIC_BASE_PATH}/team`,
|
||||
active: false
|
||||
}
|
||||
];
|
||||
]);
|
||||
let showMenuPermanent = $state(false);
|
||||
let onAdminPage = $state(false);
|
||||
let isTouch = $state(false);
|
||||
let windowHeight = $state(0);
|
||||
|
||||
let showMenuPermanent = false;
|
||||
let menuButtonScrollIndex: number | null = null;
|
||||
function onMenuButtonScroll(e: WheelEvent) {
|
||||
if (menuButtonScrollIndex == null) {
|
||||
if (e.deltaY < 0) {
|
||||
menuButtonScrollIndex = navPaths.length - 1;
|
||||
} else if (e.deltaY > 0) {
|
||||
menuButtonScrollIndex = 0;
|
||||
} else {
|
||||
menuButtonScrollIndex = navPaths.length - 1;
|
||||
}
|
||||
} else {
|
||||
navPaths[menuButtonScrollIndex].active = false;
|
||||
|
||||
if (e.deltaY > 0) {
|
||||
menuButtonScrollIndex++;
|
||||
} else if (e.deltaY < 0) {
|
||||
menuButtonScrollIndex--;
|
||||
}
|
||||
|
||||
if (menuButtonScrollIndex > navPaths.length - 1) {
|
||||
menuButtonScrollIndex = 0;
|
||||
} else if (menuButtonScrollIndex < 0) {
|
||||
menuButtonScrollIndex = navPaths.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
navPaths[menuButtonScrollIndex].active = true;
|
||||
}
|
||||
|
||||
let onAdminPage = false;
|
||||
$: onAdminPage =
|
||||
$effect(() => {
|
||||
onAdminPage =
|
||||
$page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
|
||||
$page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
|
||||
});
|
||||
$effect(() => {
|
||||
for (let i = 0; i < navPaths.length; i++) {
|
||||
navPaths[i].active = navPaths[i].href === $page.url.pathname;
|
||||
}
|
||||
});
|
||||
|
||||
let isTouch = false;
|
||||
let nav: HTMLDivElement;
|
||||
let settings: HTMLDialogElement;
|
||||
let popupModalState: PopupModalContextArgs | null = $state(null);
|
||||
// eslint-disable-next-line no-undef
|
||||
let popupModalNullTimeout: number | NodeJS.Timeout | null = null;
|
||||
setContext('globalPopupModal', {
|
||||
set: async ({ title, text, actions, onClose }: PopupModalContextArgs) => {
|
||||
if (popupModalNullTimeout) clearTimeout(popupModalNullTimeout);
|
||||
popupModalState = { title, text, actions, onClose };
|
||||
await tick();
|
||||
popupModalElem.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
let navElem: HTMLDivElement;
|
||||
let popupModalElem: HTMLDialogElement;
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
|
||||
<svelte:body
|
||||
on:keyup={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (settings.open) {
|
||||
settings.close();
|
||||
} else {
|
||||
settings.show();
|
||||
}
|
||||
}
|
||||
}}
|
||||
on:touchend={(e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (isTouch && !nav.contains(e.target)) showMenuPermanent = false;
|
||||
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<svelte:head>
|
||||
{#if !onAdminPage}
|
||||
<meta property="og:url" content={$page.url.toString()} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="{env.PUBLIC_BASE_PATH}/img/logo-512.webp" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<div class="h-screen w-full">
|
||||
<slot />
|
||||
<div class="min-h-[calc(100vh-3.5rem)] h-full w-full" class:min-h-screen={onAdminPage}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<nav>
|
||||
<div
|
||||
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center"
|
||||
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"
|
||||
class:hidden={onAdminPage}
|
||||
bind:this={nav}
|
||||
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'}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (!isTouch) {
|
||||
let activePath = navPaths.find((path) => path.active);
|
||||
if (activePath !== undefined) {
|
||||
goto(activePath.href);
|
||||
} else if ($playAudio) {
|
||||
new Audio(
|
||||
`${env.PUBLIC_BASE_PATH}/aud/chest-${showMenuPermanent ? 'close' : 'open'}.mp3`
|
||||
).play();
|
||||
}
|
||||
showMenuPermanent = !showMenuPermanent;
|
||||
}
|
||||
}}
|
||||
on:touchend={() => {
|
||||
ontouchend={() => {
|
||||
isTouch = true;
|
||||
showMenuPermanent = !showMenuPermanent;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
if (menuButtonScrollIndex !== null) {
|
||||
navPaths[menuButtonScrollIndex].active = false;
|
||||
}
|
||||
menuButtonScrollIndex = null;
|
||||
}}
|
||||
on:wheel|preventDefault={onMenuButtonScroll}
|
||||
>
|
||||
<img
|
||||
class="absolute w-full h-full p-1 pixelated"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/menu-button.png"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/menu-button.webp"
|
||||
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={isTouch && showMenuPermanent}
|
||||
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.png"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.webp"
|
||||
alt="menu hover"
|
||||
/>
|
||||
</button>
|
||||
@ -141,28 +142,33 @@
|
||||
class:hidden={!showMenuPermanent}
|
||||
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
|
||||
>
|
||||
<ul class="flex flex-col bg-base-200 rounded">
|
||||
<ul class="bg-base-200 rounded">
|
||||
{#each navPaths as navPath, i}
|
||||
<li
|
||||
class="flex justify-center tooltip tooltip-left sm:tooltip-right"
|
||||
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={() => goto(navPath.href)}
|
||||
>
|
||||
<div
|
||||
style="background-image: url('{env.PUBLIC_BASE_PATH}/img/menu-inventory-bar.png'); background-position: -{i *
|
||||
style="background-image: url('{env.PUBLIC_BASE_PATH}/img/menu-inventory-bar.webp'); 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="{env.PUBLIC_BASE_PATH}/img/selected-frame.png"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/selected-frame.webp"
|
||||
alt="menu hover"
|
||||
/>
|
||||
</a>
|
||||
@ -172,13 +178,81 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{#if !onAdminPage && $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/register`}
|
||||
<footer>
|
||||
<div
|
||||
class="flex justify-around items-center h-14 w-full bg-base-300"
|
||||
class:hidden={onAdminPage}
|
||||
>
|
||||
<div class="hidden sm:block">
|
||||
<p>© {new Date().getFullYear()} mhsl.eu</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<a class="link" href="https://mhsl.eu/id.html" target="_blank">Impressum</a>
|
||||
<a class="link" href="https://mhsl.eu/datenschutz.html" target="_blank">Datenschutz</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<dialog class="modal" bind:this={settings}>
|
||||
<form method="dialog" class="modal-box" style="overflow: unset">
|
||||
<dialog class="modal" bind:this={popupModalElem}>
|
||||
{#if popupModalState}
|
||||
<form
|
||||
method="dialog"
|
||||
class="modal-box z-50"
|
||||
onsubmit={() => {
|
||||
popupModalNullTimeout = setTimeout(() => {
|
||||
popupModalState = null;
|
||||
popupModalNullTimeout = null;
|
||||
}, 200);
|
||||
popupModalState?.onClose?.();
|
||||
}}
|
||||
>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<Settings />
|
||||
<h3 class="font-bold text-2xl">{popupModalState.title}</h3>
|
||||
{#if popupModalState.text}
|
||||
<p class="py-4 whitespace-pre-line">{popupModalState.text}</p>
|
||||
{/if}
|
||||
{#if popupModalState.actions}
|
||||
<div class="flex flex-row space-x-1 mt-4">
|
||||
{#each popupModalState.actions as action}
|
||||
<Input type="submit" value={action.text} onclick={(e) => action.action?.(e)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<form
|
||||
method="dialog"
|
||||
class="modal-backdrop bg-[rgba(0,0,0,.2)]"
|
||||
onsubmit={() => {
|
||||
popupModalNullTimeout = setTimeout(() => {
|
||||
popupModalState = null;
|
||||
popupModalNullTimeout = null;
|
||||
}, 200);
|
||||
popupModalState?.onClose?.();
|
||||
}}
|
||||
>
|
||||
<button>close</button>
|
||||
</form>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
9
src/routes/+page.server.ts
Normal file
9
src/routes/+page.server.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { Settings } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
register_enabled:
|
||||
(await Settings.findOne({ where: { key: 'register.enabled' } }))?.value ?? true
|
||||
};
|
||||
};
|
@ -1,49 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Countdown from '$lib/components/Countdown/Countdown.svelte';
|
||||
import { IconOutline } from 'svelte-heros-v2';
|
||||
import { Clock, User, WrenchScrewdriver } from 'svelte-heros-v2';
|
||||
import Crosshairs from '$lib/components/CustomIcons/Crosshairs.svelte';
|
||||
import Skull from '$lib/components/CustomIcons/Skull.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let information = [
|
||||
{
|
||||
title: 'Das Projekt',
|
||||
description:
|
||||
'CraftAttack ist ein Vanilla-Minecraft-Projekt, bei dem zahlreiche Spieler im friedlichen Miteinander spielen. Von gemeinsamen Bauvorhaben bis hin zum kollektiven Kampf gegen den Enderdrachen können die vielfältigen Aspekte von Minecraft erkundet werden.'
|
||||
},
|
||||
{
|
||||
title: 'Events',
|
||||
description:
|
||||
'Abwechslungsreiche Events und verschiedene Minispiele sorgen dafür, dass es nie langweilig wird und garantieren somit jede Menge Spielspaß.'
|
||||
},
|
||||
{
|
||||
title: 'Voraussetzungen',
|
||||
description:
|
||||
'Jeder ist willkommen und kann mitspielen. Dazu benötigst Du nur einen Minecraft-Account und schon bist Du Teil unser Community :)'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Craftattack</title>
|
||||
<meta property="og:title" content="Craftattack" />
|
||||
<meta property="keywords" content="minecraft, craftattack, mhsl, minecraft craftattack" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="absolute top-0 left-0 h-screen w-full overflow-hidden">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
class="h-full w-full blur-sm object-cover"
|
||||
autoplay
|
||||
loop
|
||||
src="{env.PUBLIC_BASE_PATH}/vid/background.mp4"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black opacity-70" />
|
||||
</div>
|
||||
<div class="absolute top-0 w-full">
|
||||
<div class="relative h-screen">
|
||||
<div class="flex flex-col items-center w-full pt-36">
|
||||
<div class="flex flex-col min-h-screen relative">
|
||||
<div class="flex items-center xl:w-1/2 px-6 sm:px-10 min-h-screen h-full">
|
||||
<div class="flex flex-col items-center xl:items-start w-full xl:h-3/4 my-10">
|
||||
<img src="{env.PUBLIC_BASE_PATH}/img/craftattack.webp" alt="Craftattack 7" />
|
||||
<div class="flex flex-col gap-5 lg:gap-14 w-full mt-2 lg:mt-5 lg:w-10/12 h-full">
|
||||
<div>
|
||||
<div class="divider"></div>
|
||||
<div class="flex flex-col md:flex-row xl:flex-col gap-5">
|
||||
{#each information as info}
|
||||
<div>
|
||||
<h4 class="text-black dark:text-white mb-1">{info.title}</h4>
|
||||
<p>{info.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<a
|
||||
class="btn btn-outline btn-accent hover:bg-white"
|
||||
href="{env.PUBLIC_BASE_PATH}/register"
|
||||
>{data.register_enabled ? 'Jetzt registrieren' : 'Infos zur Anmeldung'}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden xl:block absolute top-0 left-0 h-full w-full"
|
||||
style="clip-path: polygon(60% 0, 100% 0, 100% 100%, 40% 100%);"
|
||||
>
|
||||
<img
|
||||
class="w-11/12 sm:w-3/4 pointer-events-none"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/craftattack.webp"
|
||||
alt="Craftattack 6"
|
||||
width="1905"
|
||||
height="188"
|
||||
src="{env.PUBLIC_BASE_PATH}/img/bg.webp"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
<div class="mt-4 sm:mt-10 lg:mt-16">
|
||||
</div>
|
||||
<div class="hidden xl:flex justify-center absolute bottom-12 right-0 w-[60%]">
|
||||
<Countdown end={Date.parse(env.PUBLIC_START_DATE)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-4 sm:bottom-6 lg:bottom-10 w-full flex justify-center">
|
||||
<button
|
||||
on:click={() => {
|
||||
window.scrollTo(0, window.innerHeight);
|
||||
}}
|
||||
</div>
|
||||
<div class="flex justify-center py-20 bg-base-200">
|
||||
<div class="card bg-base-100 shadow-lg w-11/12 xl:w-5/12 p-10">
|
||||
<div>
|
||||
<h2 class="text-3xl text-black dark:text-white mb-8">Über uns</h2>
|
||||
<p>
|
||||
Wir sind ein kleines <a class="link" href={`${env.PUBLIC_BASE_PATH}/team`}>Team</a> von Minecraft-Enthusiasten,
|
||||
das bereits im 7. Jahr in Folge Minecraft CraftAttack organisiert. Jahr für Jahr arbeiten wir
|
||||
daran, das Spielerlebnis zu verbessern und steigeren die Teilnehmerzahl.
|
||||
</p>
|
||||
<p>
|
||||
Unser Ziel bei diesem ab dem <span class="italic"
|
||||
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
})}</span
|
||||
>
|
||||
<div class="border-2 border-white rounded-full w-10 h-16 flex justify-center items-center">
|
||||
<div class="animate-[pleaseINeedAttention_1.5s_ease-in-out_infinite]">
|
||||
<IconOutline name="chevron-double-down-outline" width="34" height="34" />
|
||||
stattfindenden Projekts ist es, sicherzustellen, dass alle Spieler eine großartige Erfahrung
|
||||
haben und alles reibungslos abläuft. Wir freuen uns immer über Anregungen und stehen Dir jederzeit
|
||||
zur Verfügung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col xl:flex-row justify-center items-center py-20 bg-base-100">
|
||||
<div>
|
||||
<h3 class="text-center text-2xl mb-6">2023/2024 in Zahlen</h3>
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="stats stats-vertical xl:stats-horizontal shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-figure">
|
||||
<WrenchScrewdriver />
|
||||
</div>
|
||||
<div class="stat-title">Abgebaute Blöcke</div>
|
||||
<div class="stat-value">35M</div>
|
||||
<div class="stat-desc"><span class="underline">9.6M</span> davon Stein</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure">
|
||||
<User />
|
||||
</div>
|
||||
<div class="stat-title">Teilnehmer</div>
|
||||
<div class="stat-value">148</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats stats-vertical xl:stats-horizontal shadow h-min xl:h-[initial]">
|
||||
<div class="stat">
|
||||
<div class="stat-figure">
|
||||
<Clock />
|
||||
</div>
|
||||
<div class="stat-title">Gesamtspielzeit</div>
|
||||
<div class="stat-value">246 Tage</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats stats-vertical xl:stats-horizontal shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-figure">
|
||||
<Crosshairs />
|
||||
</div>
|
||||
<div class="stat-title">Getötete Mobs</div>
|
||||
<div class="stat-value">1.8M</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure">
|
||||
<Skull />
|
||||
</div>
|
||||
<div class="stat-title">Spieler Tode</div>
|
||||
<div class="stat-value">3054</div>
|
||||
<div class="stat-desc"><span class="underline">552</span> davon durch andere Spieler</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { playAudio } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { IconOutline } from 'svelte-heros-v2';
|
||||
|
||||
let settings = [
|
||||
{
|
||||
name: 'Sound abspielen',
|
||||
description:
|
||||
'Manche Elemente können Sounds abspielen. Aktiviere diese Option und finde raus welche',
|
||||
store: playAudio
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl">Einstellungen</h2>
|
||||
<div>
|
||||
<div class="divider" />
|
||||
<div class="grid grid-cols-2">
|
||||
{#each settings as setting}
|
||||
<div class="flex">
|
||||
<p>{setting.name}</p>
|
||||
<span class="tooltip ml-[0.2rem] -mt-[1px]" data-tip={setting.description}>
|
||||
<IconOutline name="question-mark-circle-outline" width="16" height="16" />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle justify-self-end mr-6"
|
||||
checked={get(setting.store)}
|
||||
on:change={(e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
setting.store.set(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="divider" />
|
||||
</div>
|
||||
</div>
|
@ -1,8 +1,9 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { Admin, Report, User } from '$lib/server/database';
|
||||
import { Admin, Feedback, Report, User } from '$lib/server/database';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ route, cookies }) => {
|
||||
const session = getSession(cookies);
|
||||
@ -11,11 +12,18 @@ export const load: LayoutServerLoad = async ({ route, cookies }) => {
|
||||
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin/login`);
|
||||
|
||||
return {
|
||||
userCount: session?.permissions.userRead() ? await User.count() : null,
|
||||
reportCount: session?.permissions.reportRead()
|
||||
userCount: session?.permissions.users() ? await User.count() : null,
|
||||
reportCount: session?.permissions.reports()
|
||||
? await Report.count({ where: { draft: false, status: ['none', 'review'] } })
|
||||
: null,
|
||||
adminCount: session?.permissions.adminRead() ? await Admin.count() : null,
|
||||
self: session ? await Admin.findOne({ where: { id: session.userId } }) : null
|
||||
feedbackCount: session?.permissions.feedback()
|
||||
? await Feedback.count({ where: { content: { [Op.not]: null } } })
|
||||
: null,
|
||||
adminCount: session?.permissions.admin() ? await Admin.count() : null,
|
||||
settingsRead: session?.permissions.settings(),
|
||||
toolsRead: session?.permissions.tools(),
|
||||
self: session
|
||||
? JSON.parse(JSON.stringify(await Admin.findOne({ where: { id: session.userId } })))
|
||||
: null
|
||||
};
|
||||
};
|
||||
|
@ -1,11 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { IconOutline } from 'svelte-heros-v2';
|
||||
import {
|
||||
ArrowLeftOnRectangle,
|
||||
AdjustmentsHorizontal,
|
||||
Flag,
|
||||
UserGroup,
|
||||
Users,
|
||||
BookOpen,
|
||||
WrenchScrewdriver
|
||||
} from 'svelte-heros-v2';
|
||||
import { buttonTriggeredRequest } from '$lib/components/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { LayoutData } from './$types';
|
||||
import { adminCount, reportCount } from '$lib/stores';
|
||||
import { adminCount, errorMessage, reportCount, feedbackCount } from '$lib/stores';
|
||||
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
let transitionPrefix = $state(0);
|
||||
|
||||
async function logout() {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/logout`, {
|
||||
@ -18,56 +31,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
export let data: LayoutData;
|
||||
if (data.reportCount) $reportCount = data.reportCount;
|
||||
if (data.feedbackCount) $feedbackCount = data.feedbackCount;
|
||||
if (data.adminCount) $adminCount = data.adminCount;
|
||||
|
||||
let tabs = [
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/users`,
|
||||
icon: UserGroup,
|
||||
name: 'Registrierte Nutzer',
|
||||
badge: data.userCount,
|
||||
enabled: data.userCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/reports`,
|
||||
icon: Flag,
|
||||
name: 'Reports',
|
||||
badge: $reportCount,
|
||||
enabled: data.reportCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/feedback`,
|
||||
icon: BookOpen,
|
||||
name: 'Feedback',
|
||||
badge: $feedbackCount,
|
||||
enabled: data.feedbackCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
|
||||
icon: Users,
|
||||
name: 'Website Admins',
|
||||
badge: $adminCount,
|
||||
enabled: data.adminCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/settings`,
|
||||
icon: AdjustmentsHorizontal,
|
||||
name: 'Website Einstellungen',
|
||||
badge: null,
|
||||
enabled: data.settingsRead
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/tools`,
|
||||
icon: WrenchScrewdriver,
|
||||
name: 'Tools',
|
||||
badge: null,
|
||||
enabled: data.toolsRead
|
||||
}
|
||||
];
|
||||
|
||||
let pageTitleSuffix = $derived(
|
||||
tabs.find((t) => $page.url.pathname === t.path)?.name ??
|
||||
($page.url.pathname === `${env.PUBLIC_BASE_PATH}/admin/login` ? 'Login' : null)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Craftattack Admin{pageTitleSuffix ? ` - ${pageTitleSuffix}` : ''}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`}
|
||||
<div class="flex h-screen w-screen">
|
||||
<div class="h-full">
|
||||
<ul class="menu p-4 w-max h-full bg-base-200 text-base-content">
|
||||
{#if data.userCount != null}
|
||||
<li>
|
||||
<a href="{env.PUBLIC_BASE_PATH}/admin/users">
|
||||
<IconOutline name="user-group-outline" />
|
||||
<span class="ml-1">Registrierte Nutzer</span>
|
||||
<div class="badge">{data.userCount}</div>
|
||||
</a>
|
||||
</li>
|
||||
<div class="relative bg-base-200 w-full flex justify-center">
|
||||
<div role="tablist" class="tabs tabs-lifted">
|
||||
{#each tabs as tab, i}
|
||||
{#if tab.enabled}
|
||||
{@const Icon = tab.icon}
|
||||
<a
|
||||
role="tab"
|
||||
class="tab h-full"
|
||||
class:tab-active={$page.url.pathname === tab.path}
|
||||
href={tab.path}
|
||||
onclick={() => {
|
||||
let currIdx = tabs.findIndex((t) => $page.url.pathname === t.path);
|
||||
if (currIdx !== -1) {
|
||||
transitionPrefix = currIdx < i ? 1 : -1;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="my-2 flex items-center space-x-1">
|
||||
<Icon />
|
||||
<span>{tab.name}</span>
|
||||
{#if tab.badge != null}
|
||||
<div class="badge">{tab.badge}</div>
|
||||
{/if}
|
||||
{#if data.reportCount != null}
|
||||
<li>
|
||||
<a href="{env.PUBLIC_BASE_PATH}/admin/reports">
|
||||
<IconOutline name="flag-outline" />
|
||||
<span class="ml-1">Reports</span>
|
||||
<div class="badge">{$reportCount}</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#if data.adminCount != null}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 flex items-center h-full">
|
||||
<ul class="menu menu-vertical">
|
||||
<li>
|
||||
<a href="{env.PUBLIC_BASE_PATH}/admin/admin">
|
||||
<IconOutline name="users-outline" />
|
||||
<span class="ml-1">Website Admins</span>
|
||||
<div class="badge">{$adminCount}</div>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="mt-auto">
|
||||
<button on:click={(e) => buttonTriggeredRequest(e, logout())}>
|
||||
<IconOutline name="arrow-left-on-rectangle-outline" />
|
||||
<span class="ml-1">Ausloggen</span>
|
||||
<button onclick={(e) => buttonTriggeredRequest(e, logout())}>
|
||||
<ArrowLeftOnRectangle />
|
||||
<span>Ausloggen</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="h-full w-full overflow-y-scroll overflow-x-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#key $page.url.pathname}
|
||||
<div
|
||||
class="col-[1] row-[1] h-full w-full overflow-y-scroll overflow-x-hidden"
|
||||
in:fly={{ x: transitionPrefix * window.innerWidth, duration: !transitionPrefix ? 0 : 100 }}
|
||||
out:fly={{
|
||||
x: transitionPrefix * -window.innerWidth,
|
||||
duration: !transitionPrefix ? 0 : 100
|
||||
}}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full w-full">
|
||||
<slot />
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $errorMessage}
|
||||
<ErrorToast timeout={2000} show={$errorMessage != null}>
|
||||
<span>{$errorMessage}</span>
|
||||
</ErrorToast>
|
||||
{/if}
|
||||
|
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { BookOpen, Cog6Tooth, Flag, UserGroup, Users } from 'svelte-heros-v2';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let tabs = [
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/users`,
|
||||
icon: UserGroup,
|
||||
name: 'Registrierte Nutzer',
|
||||
enabled: data.userCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/reports`,
|
||||
icon: Flag,
|
||||
name: 'Reports',
|
||||
enabled: data.reportCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/feedback`,
|
||||
icon: BookOpen,
|
||||
name: 'Feedback',
|
||||
enabled: data.feedbackCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
|
||||
icon: Users,
|
||||
name: 'Website Admins',
|
||||
enabled: data.adminCount != null
|
||||
},
|
||||
{
|
||||
path: `${env.PUBLIC_BASE_PATH}/admin/settings`,
|
||||
icon: Cog6Tooth,
|
||||
name: 'Website Einstellungen',
|
||||
enabled: data.settingsRead
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex justify-around items-center h-screen">
|
||||
{#each tabs as tab}
|
||||
{#if tab.enabled}
|
||||
{@const Icon = tab.icon}
|
||||
<div class="flex flex-col gap-4 justify-center items-center">
|
||||
<a
|
||||
class="h-48 w-48 border flex justify-center items-center rounded-xl duration-100 hover:bg-base-200"
|
||||
href={tab.path}
|
||||
title={tab.name}
|
||||
>
|
||||
<Icon />
|
||||
</a>
|
||||
<span>{tab.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
|
||||
if (adminCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
|
||||
|
||||
let admins: (typeof Admin.prototype)[] = [];
|
||||
if (getSession(cookies, { permissions: [Permissions.AdminRead] }) != null) {
|
||||
if (getSession(cookies, { permissions: [Permissions.Admin] }) != null) {
|
||||
admins = await Admin.findAll({ raw: true, attributes: { exclude: ['password'] } });
|
||||
}
|
||||
|
||||
|
@ -1,25 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import Badges from '$lib/components/Input/Badges.svelte';
|
||||
import { IconOutline } from 'svelte-heros-v2';
|
||||
import { Check, NoSymbol, PencilSquare, Trash, UserPlus } from 'svelte-heros-v2';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
|
||||
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
|
||||
import { buttonTriggeredRequest } from '$lib/components/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { adminCount } from '$lib/stores';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let admins = $state(data.admins);
|
||||
|
||||
let allPermissionBadges = {
|
||||
'Admin Read': Permissions.AdminRead,
|
||||
'Admin Write': Permissions.AdminWrite,
|
||||
'User Read': Permissions.UserRead,
|
||||
'User Write': Permissions.UserWrite
|
||||
Admin: Permissions.Admin,
|
||||
Users: Permissions.Users,
|
||||
Reports: Permissions.Reports,
|
||||
Feedback: Permissions.Feedback,
|
||||
Settings: Permissions.Settings,
|
||||
Tools: Permissions.Tools
|
||||
};
|
||||
|
||||
let newAdminUsername: string;
|
||||
let newAdminPassword: string;
|
||||
let newAdminPermissions: number[];
|
||||
let newAdminUsername = $state('');
|
||||
let newAdminPassword = $state('');
|
||||
let newAdminPermissions = $state([]);
|
||||
|
||||
async function addAdmin(username: string, password: string, permissions: Permissions) {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, {
|
||||
@ -34,8 +42,11 @@
|
||||
let res = await response.json();
|
||||
$adminCount += 1;
|
||||
res.permissions = new Permissions(res.permissions).asArray();
|
||||
data.admins.push(res);
|
||||
data.admins = data.admins;
|
||||
admins.push(res);
|
||||
|
||||
newAdminUsername = '';
|
||||
newAdminPassword = '';
|
||||
newAdminPermissions = [];
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
@ -77,69 +88,65 @@
|
||||
await goto(`${env.PUBLIC_BASE_PATH}/`);
|
||||
} else {
|
||||
$adminCount -= 1;
|
||||
data.admins.splice(
|
||||
data.admins.findIndex((v) => v.id == id),
|
||||
admins.splice(
|
||||
admins.findIndex((v) => v.id == id),
|
||||
1
|
||||
);
|
||||
data.admins = data.admins;
|
||||
admins = admins;
|
||||
}
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
let errorMessage = '';
|
||||
|
||||
export let data: PageData;
|
||||
let permissions = new Permissions(data.permissions);
|
||||
let permissions = $state(new Permissions(data.permissions));
|
||||
</script>
|
||||
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Benutzername</th>
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Passwort</th>
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Berechtigungen</th>
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
|
||||
<th></th>
|
||||
<th>Benutzername</th>
|
||||
<th>Passwort</th>
|
||||
<th>Berechtigungen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.admins as admin, i}
|
||||
{#each admins as admin, i}
|
||||
<tr>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input
|
||||
type="text"
|
||||
bind:value={admin.username}
|
||||
disabled={!permissions.adminWrite() || !admin.edit}
|
||||
size="sm"
|
||||
/></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
<td>{i + 1}</td>
|
||||
<td><Input type="text" bind:value={admin.username} disabled={!admin.edit} size="sm" /></td>
|
||||
<td
|
||||
><Input
|
||||
type="password"
|
||||
bind:value={admin.password}
|
||||
placeholder="Neues Passwort..."
|
||||
disabled={!permissions.adminWrite() || !admin.edit}
|
||||
disabled={!admin.edit}
|
||||
size="sm"
|
||||
/></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
<td
|
||||
><Badges
|
||||
bind:value={admin.permissions}
|
||||
available={allPermissionBadges}
|
||||
disabled={!permissions.adminWrite() || !admin.edit}
|
||||
disabled={!admin.edit}
|
||||
/></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<div>
|
||||
{#if admin.edit}
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
disabled={!permissions.adminWrite()}
|
||||
on:click={async (e) => {
|
||||
onclick={async (e) => {
|
||||
showPopupModal({
|
||||
title: 'Speichern',
|
||||
text: `Sollen die Änderungen für den Admin '${admin.username}' gespeichert werden?`,
|
||||
actions: [
|
||||
{
|
||||
text: 'Speichern',
|
||||
action: async () => {
|
||||
await buttonTriggeredRequest(
|
||||
e,
|
||||
updateAdmin(
|
||||
@ -151,43 +158,78 @@
|
||||
);
|
||||
admin.password = '';
|
||||
admin.edit = false;
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="check-outline" width="18" height="18" />
|
||||
<Check size="18" />
|
||||
</button>
|
||||
</span>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
disabled={!permissions.adminWrite()}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (
|
||||
admin.username === admin.before.username &&
|
||||
admin.password === admin.before.password &&
|
||||
JSON.stringify(admin.permissions) === JSON.stringify(admin.before.permissions)
|
||||
) {
|
||||
admin.edit = false;
|
||||
admin = admin.before;
|
||||
return;
|
||||
}
|
||||
|
||||
showPopupModal({
|
||||
title: 'Abbrechen',
|
||||
text: 'Soll die Adminbearbeitung abgebrochen werden?',
|
||||
actions: [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
action: () => {
|
||||
admin.edit = false;
|
||||
admins[i] = admin.before;
|
||||
}
|
||||
},
|
||||
{ text: 'Schließen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="no-symbol-outline" width="18" height="18" />
|
||||
<NoSymbol size="18" />
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
disabled={!permissions.adminWrite()}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
admin.before = $state.snapshot(admin);
|
||||
admin.edit = true;
|
||||
admin.before = structuredClone(admin);
|
||||
}}
|
||||
>
|
||||
<IconOutline name="pencil-square-outline" width="18" height="18" />
|
||||
<PencilSquare size="18" />
|
||||
</button>
|
||||
</span>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
|
||||
<span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
disabled={!permissions.adminWrite()}
|
||||
on:click={(e) => buttonTriggeredRequest(e, deleteAdmin(admin.id))}
|
||||
onclick={(e) => {
|
||||
showPopupModal({
|
||||
title: 'Admin löschen',
|
||||
text: `Soll der Admin ${admin.username} wirklich gelöscht werden?`,
|
||||
actions: [
|
||||
{
|
||||
text: 'Löschen',
|
||||
action: () => buttonTriggeredRequest(e, deleteAdmin(admin.id))
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="trash-outline" width="18" height="18" />
|
||||
<Trash size="18" />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
@ -196,73 +238,45 @@
|
||||
</tr>
|
||||
{/each}
|
||||
<tr>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{data.admins.length + 1}</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" bind:value={newAdminUsername} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="password" bind:value={newAdminPassword} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Badges
|
||||
bind:value={newAdminPermissions}
|
||||
available={allPermissionBadges}
|
||||
disabled={!permissions.adminWrite()}
|
||||
/></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<span
|
||||
class="w-min"
|
||||
class:cursor-not-allowed={!permissions.adminWrite() ||
|
||||
!newAdminUsername ||
|
||||
!newAdminPassword}
|
||||
>
|
||||
<td>{admins.length + 1}</td>
|
||||
<td><Input type="text" bind:value={newAdminUsername} size="sm" /></td>
|
||||
<td><Input type="password" bind:value={newAdminPassword} size="sm" /></td>
|
||||
<td><Badges bind:value={newAdminPermissions} available={allPermissionBadges} /></td>
|
||||
<td>
|
||||
<span class="w-min" class:cursor-not-allowed={!newAdminUsername || !newAdminPassword}>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
disabled={!permissions.adminWrite() || !newAdminUsername || !newAdminPassword}
|
||||
on:click={async (e) => {
|
||||
disabled={!newAdminUsername || !newAdminPassword}
|
||||
onclick={async (e) => {
|
||||
showPopupModal({
|
||||
title: 'Admin hinzugügen',
|
||||
text: `Soll der neue Admin ${newAdminUsername} hinzugefügt werden?`,
|
||||
actions: [
|
||||
{
|
||||
text: 'Hinzufügen',
|
||||
action: async () => {
|
||||
await buttonTriggeredRequest(
|
||||
e,
|
||||
addAdmin(newAdminUsername, newAdminPassword, new Permissions(newAdminPermissions))
|
||||
addAdmin(
|
||||
newAdminUsername,
|
||||
newAdminPassword,
|
||||
new Permissions(newAdminPermissions)
|
||||
)
|
||||
);
|
||||
newAdminUsername = '';
|
||||
newAdminPassword = '';
|
||||
newAdminPermissions = [];
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="user-plus-outline" width="18" height="18" />
|
||||
<UserPlus size="18" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ErrorToast show={errorMessage !== ''}>
|
||||
<span />
|
||||
</ErrorToast>
|
||||
|
||||
<style lang="scss">
|
||||
thead tr th,
|
||||
tbody tr td {
|
||||
@apply relative;
|
||||
|
||||
&:not(:first-child) {
|
||||
@apply border-l-[1px] border-dashed;
|
||||
|
||||
&::before {
|
||||
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
@apply border-r-[1px] border-dashed border-base-300;
|
||||
|
||||
&::after {
|
||||
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,72 +1,48 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import {
|
||||
addSession,
|
||||
deleteAllUserSessions,
|
||||
deleteSession,
|
||||
getSession,
|
||||
updateAllUserSessions
|
||||
} from '$lib/server/session';
|
||||
import { deleteAllUserSessions, getSession, updateAllUserSessions } from '$lib/server/session';
|
||||
import { Admin } from '$lib/server/database';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import { AdminDeleteSchema, AdminEditSchema, AdminListSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const username = data['username'] as string | null;
|
||||
const password = data['password'] as string | null;
|
||||
const permissions = data['permissions'] as number | null;
|
||||
const parseResult = await AdminListSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) return new Response(null, { status: 400 });
|
||||
const data = parseResult.data;
|
||||
|
||||
if (username == null || password == null || permissions == null) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
if (data.username == null || data.password == null || data.permissions == null) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = await Admin.create({
|
||||
username: username,
|
||||
password: password,
|
||||
permissions: new Permissions(permissions)
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
permissions: new Permissions(data.permissions)
|
||||
});
|
||||
|
||||
delete admin.dataValues.password;
|
||||
|
||||
return new Response(JSON.stringify(admin), {
|
||||
status: 201
|
||||
});
|
||||
return new Response(JSON.stringify(admin), { status: 201 });
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const PATCH = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const id = data['id'] as string | null;
|
||||
const parseResult = await AdminEditSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) return new Response(null, { status: 400 });
|
||||
const data = parseResult.data;
|
||||
|
||||
if (id == null) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const user = await Admin.findOne({ where: { id: data.id } });
|
||||
if (!user) return new Response(null, { status: 400 });
|
||||
|
||||
const user = await Admin.findOne({ where: { id: id } });
|
||||
if (!user) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (data['username']) user.username = data['username'];
|
||||
if (data['password']) user.password = data['password'];
|
||||
if (data['permissions']) user.permissions = new Permissions(data['permissions']);
|
||||
if (data.username) user.username = data.username;
|
||||
if (data.password) user.password = data.password;
|
||||
if (data.permissions) user.permissions = new Permissions(data.permissions);
|
||||
await user.save();
|
||||
|
||||
updateAllUserSessions(user.id, { permissions: user.permissions });
|
||||
@ -75,23 +51,16 @@ export const PATCH = (async ({ request, cookies }) => {
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const DELETE = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const id = data['id'] as number | null;
|
||||
const parseResult = await AdminDeleteSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) return new Response(null, { status: 400 });
|
||||
const data = parseResult.data;
|
||||
|
||||
if (id == null) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
await Admin.destroy({ where: { id: id } });
|
||||
deleteAllUserSessions(id);
|
||||
await Admin.destroy({ where: { id: data.id } });
|
||||
deleteAllUserSessions(data.id);
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
19
src/routes/admin/admin/schema.ts
Normal file
19
src/routes/admin/admin/schema.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AdminListSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
permissions: z.number()
|
||||
});
|
||||
|
||||
export const AdminEditSchema = z.object({
|
||||
id: z.number(),
|
||||
|
||||
username: z.string().nullish(),
|
||||
password: z.string().nullish(),
|
||||
permissions: z.number().nullish()
|
||||
});
|
||||
|
||||
export const AdminDeleteSchema = z.object({
|
||||
id: z.number()
|
||||
});
|
199
src/routes/admin/feedback/+page.svelte
Normal file
199
src/routes/admin/feedback/+page.svelte
Normal file
@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { Feedback } from '$lib/server/database';
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { fly } from 'svelte/transition';
|
||||
import HeaderBar from './HeaderBar.svelte';
|
||||
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
|
||||
import { MagnifyingGlass, Share } from 'svelte-heros-v2';
|
||||
import { goto } from '$app/navigation';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Textarea from '$lib/components/Input/Textarea.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let feedbacks: (typeof Feedback.prototype.dataValues)[] = $state([]);
|
||||
let feedbacksPerRequest = 25;
|
||||
let feedbackFilter = $state({ event: null, content: null, username: null });
|
||||
let activeFeedback: typeof Feedback.prototype.dataValues | null = $state(null);
|
||||
|
||||
async function fetchFeedback(extendedFilter?: {
|
||||
limit?: number;
|
||||
from?: number;
|
||||
hash?: string;
|
||||
preview?: boolean;
|
||||
}): Promise<Feedback[]> {
|
||||
if (!browser) return [];
|
||||
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/feedback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...feedbackFilter,
|
||||
preview: extendedFilter?.preview ?? true,
|
||||
hash: extendedFilter?.hash ?? undefined,
|
||||
limit: extendedFilter?.limit ?? feedbacksPerRequest,
|
||||
from: extendedFilter?.from ?? feedbacks.length
|
||||
})
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function openHashReport() {
|
||||
if (!window.location.hash) return;
|
||||
|
||||
const requestedHash = window.location.hash.substring(1);
|
||||
|
||||
const hashFeedback = await fetchFeedback({ hash: requestedHash, preview: false });
|
||||
if (!hashFeedback) {
|
||||
await goto(window.location.href.split('#')[0], { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
activeFeedback = hashFeedback[0];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) window.addEventListener('hashchange', openHashReport);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (browser) window.removeEventListener('hashchange', openHashReport);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-row">
|
||||
<div class="w-full flex flex-col overflow-hidden">
|
||||
<HeaderBar
|
||||
bind:feedbackFilter
|
||||
onUpdate={() => fetchFeedback({ from: 0 }).then((r) => (feedbacks = r))}
|
||||
/>
|
||||
<hr class="divider my-1 mx-8 border-none" />
|
||||
<table class="table table-fixed h-fit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Titel</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Datum</th>
|
||||
<th>Inhalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<PaginationTableBody
|
||||
onUpdate={async () =>
|
||||
await fetchFeedback().then((feedback) => (feedbacks = [...feedbacks, ...feedback]))}
|
||||
>
|
||||
{#each feedbacks as feedback}
|
||||
<tr
|
||||
class="hover [&>*]:text-sm cursor-pointer"
|
||||
class:bg-base-200={activeFeedback?.url_hash === feedback.url_hash}
|
||||
onclick={async () => {
|
||||
await goto(`${window.location.href.split('#')[0]}#${feedback.url_hash}`, {
|
||||
replaceState: true
|
||||
});
|
||||
await openHashReport();
|
||||
}}
|
||||
>
|
||||
<td title={feedback.event}>{feedback.event}</td>
|
||||
<td class="overflow-hidden overflow-ellipsis">{feedback.title}</td>
|
||||
<td class="flex">
|
||||
{feedback.user?.username || ''}
|
||||
{#if feedback.user}
|
||||
<button
|
||||
class="pl-1"
|
||||
title="Nach Ersteller filtern"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
feedbackFilter.username = feedback.user.username;
|
||||
fetchFeedback({ from: 0 }).then((r) => (feedbacks = r));
|
||||
}}
|
||||
>
|
||||
<MagnifyingGlass size="14" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
>{new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(feedback.updatedAt))} Uhr</td
|
||||
>
|
||||
<td class="overflow-hidden overflow-ellipsis"
|
||||
>{feedback.content}{feedback.content_stripped ? '...' : ''}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</PaginationTableBody>
|
||||
</table>
|
||||
</div>
|
||||
{#if activeFeedback}
|
||||
<div
|
||||
class="relative flex flex-col w-2/5 h-[calc(100vh-3rem)] bg-base-200/50 px-4 py-6 overflow-scroll"
|
||||
transition:fly={{ x: 200, duration: 200 }}
|
||||
>
|
||||
<div class="absolute right-2 top-2 flex justify-center">
|
||||
<form class="dropdown dropdown-end">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center">
|
||||
<Share size="1rem" />
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeFeedback.url_hash}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Internen Link kopieren
|
||||
</button>
|
||||
<button
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeFeedback.url_hash}`
|
||||
)}>Öffentlichen Link kopieren</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={() => {
|
||||
activeFeedback = null;
|
||||
goto(window.location.href.split('#')[0], { replaceState: true });
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
<h3 class="font-roboto font-semibold text-2xl mb-2">Feedback</h3>
|
||||
<div class="w-full">
|
||||
<Input readonly={true} size="sm" value={activeFeedback.event} pickyWidth={false}>
|
||||
{#snippet label()}
|
||||
<span>Event</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input readonly={true} size="sm" value={activeFeedback.title} pickyWidth={false}>
|
||||
{#snippet label()}
|
||||
<span>Titel</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Textarea readonly={true} rows={4} label="Inhalt" value={activeFeedback.content} />
|
||||
<div class="divider mb-1"></div>
|
||||
<Input
|
||||
readonly={true}
|
||||
size="sm"
|
||||
value={activeFeedback.user?.username || ''}
|
||||
pickyWidth={false}
|
||||
>
|
||||
{#snippet label()}
|
||||
<span>Nutzer</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
58
src/routes/admin/feedback/+server.ts
Normal file
58
src/routes/admin/feedback/+server.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { FeedbackListSchema } from './schema';
|
||||
import { Feedback, sequelize, User } from '$lib/server/database';
|
||||
import { type Attributes, Op } from 'sequelize';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Feedback] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await FeedbackListSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const feedbackFindOptions: Attributes<Feedback> = {
|
||||
content: { [Op.not]: null }
|
||||
};
|
||||
if (data.event) Object.assign(feedbackFindOptions, { event: { [Op.like]: `%${data.event}%` } });
|
||||
// prettier-ignore
|
||||
if (data.content) Object.assign(feedbackFindOptions, { content: { [Op.like]: `%${data.content}%` } });
|
||||
if (data.username)
|
||||
Object.assign(feedbackFindOptions, {
|
||||
user_id: await User.findAll({
|
||||
attributes: ['id'],
|
||||
where: { username: { [Op.like]: `%${data.username}%` } }
|
||||
}).then((users) => users.map((user) => user.id))
|
||||
});
|
||||
if (data.hash) Object.assign(feedbackFindOptions, { url_hash: data.hash });
|
||||
|
||||
const feedback = await Feedback.findAll({
|
||||
where: feedbackFindOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
attributes: data.preview
|
||||
? {
|
||||
exclude: ['content'],
|
||||
include: [
|
||||
[sequelize.literal('SUBSTR(content, 1, 50)'), 'content'],
|
||||
[sequelize.literal('LENGTH(content) > 50'), 'content_stripped']
|
||||
]
|
||||
}
|
||||
: undefined,
|
||||
include: { model: User, as: 'user' },
|
||||
order: [['created_at', 'DESC']],
|
||||
offset: data.hash ? 0 : data.from || 0,
|
||||
limit: data.hash ? 1 : data.limit || 100
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(feedback));
|
||||
}) satisfies RequestHandler;
|
26
src/routes/admin/feedback/HeaderBar.svelte
Normal file
26
src/routes/admin/feedback/HeaderBar.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
|
||||
let {
|
||||
feedbackFilter = $bindable({ event: null, content: null, username: null }),
|
||||
onUpdate
|
||||
}: { feedbackFilter: { [k: string]: any }; onUpdate: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
|
||||
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.username} oninput={onUpdate}>
|
||||
{#snippet label()}
|
||||
<span>Nutzer</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.event} oninput={onUpdate}>
|
||||
{#snippet label()}
|
||||
<span>Event</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input size="sm" placeholder="Alle" bind:value={feedbackFilter.content} oninput={onUpdate}>
|
||||
{#snippet label()}
|
||||
<span>Inhalt</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
</form>
|
15
src/routes/admin/feedback/schema.ts
Normal file
15
src/routes/admin/feedback/schema.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FeedbackListSchema = z.object({
|
||||
limit: z.number().nullish(),
|
||||
from: z.number().nullish(),
|
||||
|
||||
event: z.string().nullish(),
|
||||
content: z.string().nullish(),
|
||||
|
||||
hash: z.string().nullish(),
|
||||
|
||||
username: z.string().nullish(),
|
||||
|
||||
preview: z.boolean().nullish()
|
||||
});
|
@ -1,3 +1,3 @@
|
||||
<div class="flex justify-center items-center w-full h-full">
|
||||
<div class="flex justify-center items-center w-full h-screen">
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -1,53 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { errorMessage } from '$lib/stores';
|
||||
|
||||
let password = $state('');
|
||||
|
||||
let passwordValue: string;
|
||||
async function login() {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
loginRequest = new Promise(async (resolve, reject) => {
|
||||
loginRequest = new Promise(async (resolve) => {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/login`, {
|
||||
method: 'POST',
|
||||
body: new FormData(document.forms[0])
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
|
||||
});
|
||||
if (response.ok) {
|
||||
await goto(`${env.PUBLIC_BASE_PATH}/admin`, { invalidateAll: true });
|
||||
window.location.href = `${env.PUBLIC_BASE_PATH}/admin`;
|
||||
resolve();
|
||||
} else if (response.status == 403) {
|
||||
passwordValue = '';
|
||||
showError = true;
|
||||
errorToastElement.reset();
|
||||
} else if (response.status == 401) {
|
||||
password = '';
|
||||
$errorMessage = 'Nutzername oder Passwort falsch';
|
||||
resolve();
|
||||
} else {
|
||||
reject(Error(`${response.statusText} (${response.status})`));
|
||||
$errorMessage = `${response.statusText} (${response.status})`;
|
||||
}
|
||||
loginRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
let loginRequest: Promise<void> | null = null;
|
||||
let showError = false;
|
||||
let errorToastElement: ErrorToast;
|
||||
let loginRequest: Promise<void> | null = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="card px-14 py-6 shadow-lg">
|
||||
<h1 class="text-center text-4xl mt-2 mb-4">Craftattack Admin Login</h1>
|
||||
<form class="flex flex-col items-center" on:submit|preventDefault={login}>
|
||||
<form
|
||||
class="flex flex-col items-center"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login();
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="grid gap-4">
|
||||
<Input id="username" name="username" type="text" required={true}>
|
||||
<span slot="label">Nutzername</span>
|
||||
{#snippet label()}
|
||||
<span>Nutzername</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required={true}
|
||||
bind:value={passwordValue}
|
||||
>
|
||||
<span slot="label">Passwort</span>
|
||||
<Input id="password" name="password" type="password" required={true} bind:value={password}>
|
||||
{#snippet label()}
|
||||
<span>Passwort</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,29 +61,10 @@
|
||||
{#await loginRequest}
|
||||
<span
|
||||
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
|
||||
/>
|
||||
{:catch error}
|
||||
<dialog
|
||||
class="modal"
|
||||
on:close={() => setTimeout(() => (loginRequest = null), 200)}
|
||||
open
|
||||
>
|
||||
<form method="dialog" class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<h3 class="font-bold text-lg">Error</h3>
|
||||
<p class="py-4">{error.message}</p>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
></span>
|
||||
{/await}
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ErrorToast timeout={2000} bind:show={showError} bind:this={errorToastElement}>
|
||||
<span>Nutzername oder Passwort falsch</span>
|
||||
</ErrorToast>
|
||||
|
@ -4,23 +4,20 @@ import { env as publicEnv } from '$env/dynamic/public';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { addSession, sessionCookieName } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { LoginSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username') as string | null;
|
||||
const password = data.get('password') as string | null;
|
||||
|
||||
if (username == null || password == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
const parseResult = await LoginSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
if (
|
||||
env.ADMIN_USER &&
|
||||
env.ADMIN_PASSWORD &&
|
||||
username == env.ADMIN_USER &&
|
||||
password == env.ADMIN_PASSWORD
|
||||
data.username == env.ADMIN_USER &&
|
||||
data.password == env.ADMIN_PASSWORD
|
||||
) {
|
||||
cookies.set(
|
||||
sessionCookieName,
|
||||
@ -35,8 +32,8 @@ export const POST = (async ({ request, cookies }) => {
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const user = await Admin.findOne({ where: { username: username } });
|
||||
if (user && user.validatePassword(password)) {
|
||||
const user = await Admin.findOne({ where: { username: data.username } });
|
||||
if (user && user.validatePassword(data.password)) {
|
||||
cookies.set(sessionCookieName, addSession(user), {
|
||||
path: `${publicEnv.PUBLIC_BASE_PATH}/admin`,
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
@ -45,6 +42,7 @@ export const POST = (async ({ request, cookies }) => {
|
||||
});
|
||||
return new Response();
|
||||
} else {
|
||||
console.log(`failed login attempt for user ${data.username}`);
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
|
6
src/routes/admin/login/schema.ts
Normal file
6
src/routes/admin/login/schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string()
|
||||
});
|
@ -1,5 +1,6 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { deleteSession, getSession, sessionCookieName } from '$lib/server/session';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
export const POST = (async ({ cookies }) => {
|
||||
if (getSession(cookies) == null) {
|
||||
@ -9,7 +10,7 @@ export const POST = (async ({ cookies }) => {
|
||||
}
|
||||
|
||||
deleteSession(cookies);
|
||||
cookies.delete(sessionCookieName);
|
||||
cookies.delete(sessionCookieName, { path: `${publicEnv.PUBLIC_BASE_PATH}/admin` });
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
@ -1,17 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { StrikeReason } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, cookies }) => {
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { reportCount } = await parent();
|
||||
if (reportCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
|
||||
|
||||
const { self } = await parent();
|
||||
|
||||
return {
|
||||
count: getSession(cookies, { permissions: [Permissions.UserRead] }) != null ? reportCount : 0,
|
||||
strike_reasons: JSON.parse(JSON.stringify(await StrikeReason.findAll())),
|
||||
self: self
|
||||
};
|
||||
};
|
||||
|
@ -1,75 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
import type { Report } from '$lib/server/database';
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Textarea from '$lib/components/Input/Textarea.svelte';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { reportCount } from '$lib/stores';
|
||||
import HeaderBar from './HeaderBar.svelte';
|
||||
import { MagnifyingGlass, Plus, Share } from 'svelte-heros-v2';
|
||||
import NewReportModal from './NewReportModal.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Search from '$lib/components/Input/Search.svelte';
|
||||
import { usernameSuggestions } from '$lib/utils';
|
||||
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
export let data: PageData;
|
||||
let { data } = $props();
|
||||
|
||||
let currentPageReports: (typeof Report.prototype.dataValues)[] = [];
|
||||
let reportsPerPage = 50;
|
||||
let reportPage = 0;
|
||||
let reportFilter = { draft: false, status: null, reporter: null, reported: null };
|
||||
let activeReport: typeof Report.prototype.dataValues | null = null;
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
async function fetchPageReports(
|
||||
page: number,
|
||||
filter: any
|
||||
): Promise<(typeof Report.prototype.dataValues)[]> {
|
||||
if (!browser) return [];
|
||||
let reports: (typeof Report.prototype.dataValues)[] = $state([]);
|
||||
let reportsPerRequest = 25;
|
||||
let reportFilter = $state({ draft: false, status: null, reporter: null, reported: null });
|
||||
let activeReport: typeof Report.prototype.dataValues | null = $state(null);
|
||||
|
||||
async function fetchReports(extendedFilter?: {
|
||||
hash?: string;
|
||||
limit?: number;
|
||||
from?: number;
|
||||
}): Promise<{ reports: typeof reports; count: number }> {
|
||||
if (!browser) return { reports: [], count: 0 };
|
||||
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...filter, limit: reportsPerPage, from: reportPage * page })
|
||||
body: JSON.stringify({
|
||||
...reportFilter,
|
||||
limit: extendedFilter?.limit ?? reportsPerRequest,
|
||||
from: extendedFilter?.from ?? reports.length,
|
||||
hash: extendedFilter?.hash
|
||||
})
|
||||
});
|
||||
|
||||
if (activeReport) {
|
||||
activeReport = null;
|
||||
await goto(window.location.href.split('#')[0], { replaceState: true });
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
$: fetchPageReports(reportPage, reportFilter).then((r) => (currentPageReports = r));
|
||||
async function openHashReport() {
|
||||
if (!window.location.hash) return;
|
||||
|
||||
const requestedHash = window.location.hash.substring(1);
|
||||
let report = reports.find((r) => r.url_hash === requestedHash);
|
||||
if (!report) {
|
||||
const hashReport = (await fetchReports({ hash: requestedHash })).reports[0];
|
||||
if (hashReport) {
|
||||
reports = [hashReport, ...reports];
|
||||
report = hashReport;
|
||||
} else {
|
||||
await goto(window.location.href.split('#')[0], { replaceState: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
activeReport = report;
|
||||
activeReport.originalStatus = report;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) window.addEventListener('hashchange', openHashReport);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (browser) window.removeEventListener('hashchange', openHashReport);
|
||||
});
|
||||
|
||||
async function updateActiveReport() {
|
||||
await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
id: activeReport.id,
|
||||
auditor: data.self?.id || -1,
|
||||
notice: activeReport.notice || '',
|
||||
statement: activeReport.statement || '',
|
||||
status: activeReport.status
|
||||
auditor: data.self?.id ?? -1,
|
||||
notice: activeReport.notice ?? '',
|
||||
statement: activeReport.statement ?? '',
|
||||
status: activeReport.status,
|
||||
reported: activeReport.reported?.uuid ?? null,
|
||||
strike_reason: activeReport.strike_reason_id ?? null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let saveActiveReportChangesModal: HTMLDialogElement;
|
||||
let newReportModal: HTMLDialogElement;
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-row">
|
||||
<div class="w-full flex flex-col">
|
||||
<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
|
||||
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}>
|
||||
<span slot="label">Report Ersteller</span>
|
||||
</Input>
|
||||
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}>
|
||||
<span slot="label">Reportete Spieler</span>
|
||||
</Input>
|
||||
<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}>
|
||||
<option value="none">Unbearbeitet</option>
|
||||
<option value="review">In Bearbeitung</option>
|
||||
<option value={null}>Unbearbeitet & In Bearbeitung</option>
|
||||
<option value="reviewed">Bearbeitet</option>
|
||||
</Select>
|
||||
<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}>
|
||||
<option value={false}>Erstellt</option>
|
||||
<option value={true}>Entwurf</option>
|
||||
<option value={null}>Erstellt & Entwurf</option>
|
||||
</Select>
|
||||
</form>
|
||||
<div class="h-full flex flex-row">
|
||||
<div class="w-full flex flex-col overflow-scroll">
|
||||
<div class="grid grid-cols-[5fr_1fr_10fr_1fr_5fr]">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<HeaderBar
|
||||
bind:reportFilter
|
||||
onUpdate={() => fetchReports({ from: 0 }).then((r) => (reports = r.reports))}
|
||||
/>
|
||||
<div class="divider divider-horizontal my-auto h-3/4"></div>
|
||||
<div class="flex items-center">
|
||||
<button class="btn" onclick={() => newReportModal.show()}>
|
||||
<Plus />
|
||||
<span>Neuer Report</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider my-1 mx-8 border-none" />
|
||||
<table class="table table-fixed h-fit">
|
||||
<colgroup>
|
||||
@ -90,19 +134,53 @@
|
||||
<th>Reportstatus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each currentPageReports as report}
|
||||
<PaginationTableBody
|
||||
onUpdate={async () =>
|
||||
await fetchReports().then((res) => (reports = [...reports, ...res.reports]))}
|
||||
>
|
||||
{#each reports as report}
|
||||
<tr
|
||||
class="hover [&>*]:text-sm cursor-pointer"
|
||||
class:bg-base-200={activeReport === report}
|
||||
on:click={() => {
|
||||
activeReport = report;
|
||||
class:bg-base-200={activeReport?.url_hash === report.url_hash}
|
||||
onclick={() => {
|
||||
goto(`${window.location.href.split('#')[0]}#${report.url_hash}`, {
|
||||
replaceState: true
|
||||
});
|
||||
activeReport = $state.snapshot(report);
|
||||
activeReport.originalStatus = report.status;
|
||||
}}
|
||||
>
|
||||
<td title={report.subject}><div class="overflow-scroll">{report.subject}</div></td>
|
||||
<td>{report.reporter.username}</td>
|
||||
<td>{report.reported.username}</td>
|
||||
<td class="flex">
|
||||
{report.reporter.username}
|
||||
<button
|
||||
class="pl-1"
|
||||
title="Nach Ersteller filtern"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
reportFilter.reporter = report.reporter.username;
|
||||
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
|
||||
}}
|
||||
>
|
||||
<MagnifyingGlass size="14" />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{report.reported?.username || ''}
|
||||
{#if report.reported?.id}
|
||||
<button
|
||||
class="pl-1"
|
||||
title="Nach Reportetem Spieler filtern"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
reportFilter.reported = report.reported.username;
|
||||
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
|
||||
}}
|
||||
>
|
||||
<MagnifyingGlass size="14" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
>{new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
@ -124,35 +202,78 @@
|
||||
<td>{report.draft ? 'Entwurf' : 'Erstellt'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</PaginationTableBody>
|
||||
</table>
|
||||
</div>
|
||||
{#if activeReport}
|
||||
<div
|
||||
class="relative flex flex-col w-2/5 bg-base-200/50 px-4 py-6 overflow-scroll"
|
||||
class="relative flex flex-col w-2/5 h-[calc(100vh-3rem)] bg-base-200/50 px-4 py-6 overflow-scroll"
|
||||
transition:fly={{ x: 200, duration: 200 }}
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
on:click={() => (activeReport = null)}>✕</button
|
||||
<div class="absolute right-2 top-2 flex justify-center">
|
||||
<form class="dropdown dropdown-end">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center">
|
||||
<Share size="1rem" />
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeReport.url_hash}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Internen Link kopieren
|
||||
</button>
|
||||
<button
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeReport.url_hash}`
|
||||
)}>Öffentlichen Link kopieren</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={() => {
|
||||
activeReport = null;
|
||||
goto(window.location.href.split('#')[0], { replaceState: true });
|
||||
}}>✕</button
|
||||
>
|
||||
<h3 class="font-roboto font-semibold text-2xl">Report</h3>
|
||||
<div class="break-words my-2">
|
||||
<i class="font-medium">{activeReport.reporter.username}</i> hat
|
||||
<i class="font-medium">{activeReport.reported.username}</i>
|
||||
am {new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(activeReport.createdAt))} Uhr reportet.
|
||||
</div>
|
||||
<h3 class="font-roboto font-semibold text-2xl mb-2">Report</h3>
|
||||
<div class="w-full">
|
||||
<Input readonly={true} size="sm" value={activeReport.reporter.username} pickyWidth={false}>
|
||||
{#snippet label()}
|
||||
<span>Reporter</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Search
|
||||
size="sm"
|
||||
suggestionRequired={true}
|
||||
emptyAllowed={true}
|
||||
searchSuggestionFunc={usernameSuggestions}
|
||||
invalidMessage="Es können nur registrierte Spieler reportet werden"
|
||||
label="Reporteter User"
|
||||
value={activeReport.reported?.username || ''}
|
||||
onsubmit={(e) =>
|
||||
(activeReport.reported = {
|
||||
...activeReport.reported,
|
||||
username: e.input,
|
||||
uuid: e.value
|
||||
})}
|
||||
/>
|
||||
<Textarea readonly={true} rows={1} label="Report Grund" value={activeReport.subject} />
|
||||
<Textarea readonly={true} rows={4} label="Report Details" value={activeReport.body} />
|
||||
</div>
|
||||
<div class="divider mx-4" />
|
||||
<div class="divider mx-4"></div>
|
||||
<div>
|
||||
<div
|
||||
class="w-full"
|
||||
@ -162,7 +283,7 @@
|
||||
>
|
||||
<Textarea
|
||||
label="Interne Notizen"
|
||||
readonly={activeReport.auditor === null && activeReport.notice === null}
|
||||
readonly={activeReport.status === 'none'}
|
||||
rows={1}
|
||||
bind:value={activeReport.notice}
|
||||
/>
|
||||
@ -175,42 +296,65 @@
|
||||
>
|
||||
<Textarea
|
||||
label="(Öffentliche) Report Antwort"
|
||||
readonly={activeReport.auditor === null && activeReport.notice === null}
|
||||
readonly={activeReport.status === 'none'}
|
||||
rows={3}
|
||||
bind:value={activeReport.statement}
|
||||
/>
|
||||
</div>
|
||||
<Select label="Bearbeitungsstatus" size="sm" bind:value={activeReport.status}>
|
||||
<option value="none" disabled={activeReport.auditor != null || activeReport.notice}
|
||||
<option
|
||||
value="none"
|
||||
disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement}
|
||||
>Unbearbeitet</option
|
||||
>
|
||||
<option value="review">In Bearbeitung</option>
|
||||
<option value="reviewed">Bearbeitet</option>
|
||||
</Select>
|
||||
<div>
|
||||
<Select
|
||||
label="Vergehen"
|
||||
size="sm"
|
||||
disabled={activeReport.status === 'none' || !activeReport.reported}
|
||||
bind:value={activeReport.strike_reason_id}
|
||||
>
|
||||
<option value={-1}>Keins</option>
|
||||
{#each data.strike_reasons as strike_reason}
|
||||
<option value={strike_reason.id}>{strike_reason.name} ({strike_reason.weight})</option
|
||||
>
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="self-end mt-auto pt-6 w-full flex justify-center">
|
||||
<Input
|
||||
type="submit"
|
||||
value="Speichern"
|
||||
on:click={() => saveActiveReportChangesModal.show()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dialog class="modal" bind:this={saveActiveReportChangesModal}>
|
||||
<form method="dialog" class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<h3 class="font-roboto text-xl">Änderungen Speichern?</h3>
|
||||
<div class="flex flex-row space-x-2 mt-6">
|
||||
<Input
|
||||
type="submit"
|
||||
value="Speichern"
|
||||
on:click={async () => {
|
||||
onclick={() => {
|
||||
showPopupModal({
|
||||
title: 'Änderungen Speichern?',
|
||||
actions: [
|
||||
{
|
||||
text: 'Speichern',
|
||||
action: async () => {
|
||||
await updateActiveReport();
|
||||
currentPageReports = [...currentPageReports];
|
||||
if (activeReport.originalStatus !== 'reviewed' && activeReport.status === 'reviewed') {
|
||||
if (activeReport.reported?.username) {
|
||||
if (activeReport.reported?.id === undefined) {
|
||||
activeReport.reported.id = -1;
|
||||
}
|
||||
} else {
|
||||
activeReport.reported = undefined;
|
||||
}
|
||||
const activeReportIndex = reports.findIndex((r) => r.id === activeReport.id);
|
||||
if (activeReportIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
reports[activeReportIndex] = activeReport;
|
||||
|
||||
if (
|
||||
activeReport.originalStatus !== 'reviewed' &&
|
||||
activeReport.status === 'reviewed'
|
||||
) {
|
||||
$reportCount -= 1;
|
||||
} else if (
|
||||
activeReport.originalStatus === 'reviewed' &&
|
||||
@ -218,12 +362,25 @@
|
||||
) {
|
||||
$reportCount += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input type="submit" value="Abbrechen" />
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dialog class="modal" bind:this={newReportModal}>
|
||||
<NewReportModal
|
||||
onSubmit={(e) => {
|
||||
if (!e.draft) $reportCount += 1;
|
||||
reports = [e, ...reports];
|
||||
activeReport = $state.snapshot(reports[0]);
|
||||
newReportModal.close();
|
||||
}}
|
||||
/>
|
||||
</dialog>
|
||||
|
@ -1,31 +1,32 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { Admin, Report, User } from '$lib/server/database';
|
||||
import { Admin, Report, StrikeReason, User } from '$lib/server/database';
|
||||
import type { Attributes } from 'sequelize';
|
||||
import { Op } from 'sequelize';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import crypto from 'crypto';
|
||||
import { webhookUserReported } from '$lib/server/webhook';
|
||||
import { ReportAddSchema, ReportEditSchema, ReportListSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.ReportRead] }) == null) {
|
||||
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const data: {
|
||||
limit: number | null;
|
||||
from: number | null;
|
||||
const parseResult = await ReportListSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
draft: boolean | null;
|
||||
status: 'none' | 'review' | 'reviewed' | null;
|
||||
reporter: string | null;
|
||||
reported: string | null;
|
||||
} = await request.json();
|
||||
|
||||
const reportFindOptions: Attributes<Report> = {};
|
||||
if (data.draft != null) reportFindOptions.draft = data.draft;
|
||||
reportFindOptions.status = data.status == null ? ['none', 'review'] : data.status;
|
||||
let reportFindOptions: Attributes<Report> = {};
|
||||
reportFindOptions.draft = data.draft;
|
||||
reportFindOptions.status = data.status ?? ['none', 'review'];
|
||||
if (data.reporter != null) {
|
||||
const reporter_ids = await User.findAll({
|
||||
attributes: ['id'],
|
||||
@ -40,14 +41,17 @@ export const POST = (async ({ request, cookies }) => {
|
||||
});
|
||||
reportFindOptions.reported_id = reported_ids.map((u) => u.id);
|
||||
}
|
||||
|
||||
if (data.hash != null) {
|
||||
reportFindOptions = { url_hash: data.hash };
|
||||
data.from = 0;
|
||||
data.limit = 1;
|
||||
}
|
||||
|
||||
let reports = await Report.findAll({
|
||||
where: reportFindOptions,
|
||||
include: [
|
||||
{ model: User, as: 'reporter' },
|
||||
{ model: User, as: 'reported' },
|
||||
{ model: Admin, as: 'auditor' }
|
||||
],
|
||||
order: ['created_at'],
|
||||
include: [{ all: true }],
|
||||
order: [['created_at', 'DESC']],
|
||||
offset: data.from || 0,
|
||||
limit: data.limit || 100
|
||||
});
|
||||
@ -67,40 +71,129 @@ export const POST = (async ({ request, cookies }) => {
|
||||
} else if (r.auditor) {
|
||||
delete r.dataValues.auditor.password;
|
||||
}
|
||||
if (!r.strike_reason) {
|
||||
r.strike_reason_id = -1;
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(reports));
|
||||
return new Response(
|
||||
JSON.stringify({ reports: reports, count: await Report.count({ where: reportFindOptions }) })
|
||||
);
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const PATCH = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.ReportWrite] }) == null) {
|
||||
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const data: {
|
||||
id: number;
|
||||
auditor: number;
|
||||
notice: string | null;
|
||||
statement: string | null;
|
||||
status: 'none' | 'review' | 'reviewed' | null;
|
||||
} = await request.json();
|
||||
const parseResult = await ReportEditSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
if (data.id === null || data.auditor === null) return new Response(null, { status: 400 });
|
||||
|
||||
const report = await Report.findOne({ where: { id: data.id } });
|
||||
const report = await Report.findOne({ where: { id: data.id }, include: [{ all: true }] });
|
||||
const admin = await Admin.findOne({ where: { id: data.auditor } });
|
||||
const reported = data.reported ? await User.findOne({ where: { uuid: data.reported } }) : null;
|
||||
if (report === null || (admin === null && data.auditor != -1))
|
||||
return new Response(null, { status: 400 });
|
||||
|
||||
const webhookTriggerUsers: string[] = [];
|
||||
|
||||
// check if strike reason has changed and return 400 if it doesn't exist
|
||||
if (
|
||||
(report.strike_reason?.id ?? -1) != data.strike_reason &&
|
||||
data.strike_reason != null &&
|
||||
data.strike_reason != -1
|
||||
) {
|
||||
const strike_reason = await StrikeReason.findByPk(data.strike_reason);
|
||||
if (strike_reason == null) return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
if (data.status === 'reviewed') {
|
||||
// trigger webhook if status changed to reviewed
|
||||
if (report.status !== 'reviewed' && data.strike_reason != -1 && reported) {
|
||||
webhookTriggerUsers.push(reported.uuid!);
|
||||
}
|
||||
// trigger webhook if strike reason has changed
|
||||
else if (
|
||||
(report.strike_reason?.id ?? -1) != data.strike_reason &&
|
||||
report.reported &&
|
||||
reported
|
||||
) {
|
||||
webhookTriggerUsers.push(reported.uuid!);
|
||||
}
|
||||
} else if (report.status === 'reviewed') {
|
||||
// trigger webhook if report status is reviewed and reported user has changed
|
||||
if (report.strike_reason != null && report.reported) {
|
||||
webhookTriggerUsers.push(report.reported.uuid!);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.notice != null) report.notice = data.notice;
|
||||
if (data.statement != null) report.statement = data.statement;
|
||||
if (data.status != null) report.status = data.status;
|
||||
if (data.reported != null && reported) report.reported_id = reported.id;
|
||||
if (data.strike_reason != null)
|
||||
report.strike_reason_id = data.strike_reason == -1 ? null : data.strike_reason;
|
||||
if (data.strike_reason != null)
|
||||
report.striked_at = data.strike_reason == -1 ? null : new Date(Date.now());
|
||||
if (admin != null) report.auditor_id = admin.id;
|
||||
|
||||
await report.save();
|
||||
|
||||
for (const webhookTriggerUser of webhookTriggerUsers) {
|
||||
// no `await` to avoid blocking
|
||||
webhookUserReported(env.REPORTED_WEBHOOK, webhookTriggerUser).catch((e) =>
|
||||
console.error(`failed to send reported webhook: ${e}`)
|
||||
);
|
||||
}
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const PUT = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await ReportAddSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const reporter = await User.findOne({ where: { uuid: data.reporter } });
|
||||
const reported = data.reported ? await User.findOne({ where: { uuid: data.reported } }) : null;
|
||||
|
||||
if (reporter == null) return new Response(null, { status: 400 });
|
||||
|
||||
const report = await Report.create({
|
||||
subject: data.reason,
|
||||
body: data.body,
|
||||
draft: data.body === null,
|
||||
status: 'none',
|
||||
url_hash: crypto.randomBytes(18).toString('hex'),
|
||||
completed: false,
|
||||
reporter_id: reporter.id,
|
||||
reported_id: reported?.id || null
|
||||
});
|
||||
report.dataValues.reporter = await User.findOne({ where: { id: report.reporter_id } });
|
||||
report.dataValues.reported = report.reported_id
|
||||
? await User.findOne({ where: { id: report.reported_id } })
|
||||
: null;
|
||||
report.dataValues.auditor = null;
|
||||
|
||||
return new Response(JSON.stringify(report), {
|
||||
status: 201
|
||||
});
|
||||
}) satisfies RequestHandler;
|
||||
|
40
src/routes/admin/reports/HeaderBar.svelte
Normal file
40
src/routes/admin/reports/HeaderBar.svelte
Normal file
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
|
||||
let {
|
||||
reportFilter = $bindable({
|
||||
reporter: undefined,
|
||||
reported: undefined,
|
||||
status: undefined,
|
||||
draft: false
|
||||
}),
|
||||
onUpdate
|
||||
}: {
|
||||
reportFilter: { reporter?: string; reported?: string; status?: string; draft: false };
|
||||
onUpdate: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
|
||||
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter} oninput={onUpdate}>
|
||||
{#snippet label()}
|
||||
<span>Report Ersteller</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported} oninput={onUpdate}>
|
||||
{#snippet label()}
|
||||
<span>Reporteter Spieler</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status} onChange={onUpdate}>
|
||||
<option value="none">Unbearbeitet</option>
|
||||
<option value="review">In Bearbeitung</option>
|
||||
<option value={null}>Unbearbeitet & In Bearbeitung</option>
|
||||
<option value="reviewed">Bearbeitet</option>
|
||||
</Select>
|
||||
<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft} onChange={onUpdate}>
|
||||
<option value={false}>Erstellt</option>
|
||||
<option value={true}>Entwurf</option>
|
||||
</Select>
|
||||
</form>
|
115
src/routes/admin/reports/NewReportModal.svelte
Normal file
115
src/routes/admin/reports/NewReportModal.svelte
Normal file
@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Textarea from '$lib/components/Input/Textarea.svelte';
|
||||
import Search from '$lib/components/Input/Search.svelte';
|
||||
import { usernameSuggestions } from '$lib/utils';
|
||||
import type { Report } from '$lib/server/database';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
let { onSubmit }: { onSubmit: (data: typeof Report.prototype.dataValues) => void } = $props();
|
||||
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let reporter: string | undefined = $state();
|
||||
let reported: string | undefined = $state();
|
||||
let reason = $state('');
|
||||
let body = $state('');
|
||||
|
||||
async function newReport() {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
reporter: reporter,
|
||||
reported: reported || null,
|
||||
reason: reason,
|
||||
body: body || null
|
||||
})
|
||||
});
|
||||
if (response.ok) onSubmit(await response.json());
|
||||
}
|
||||
|
||||
let globalCloseForm: HTMLFormElement;
|
||||
|
||||
let reportForm: HTMLFormElement;
|
||||
</script>
|
||||
|
||||
<form method="dialog" class="modal-box" bind:this={reportForm}>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
globalCloseForm.submit();
|
||||
}}>✕</button
|
||||
>
|
||||
<h3 class="font-roboto text-3xl">Neuer Report</h3>
|
||||
<div class="space-y-2 mt-2 px-1 max-h-[70vh] overflow-y-scroll">
|
||||
<div>
|
||||
<Search
|
||||
size="sm"
|
||||
suggestionRequired={true}
|
||||
searchSuggestionFunc={usernameSuggestions}
|
||||
invalidMessage="Es können nur registrierte Spieler Report Autoren sein"
|
||||
label="Reporter"
|
||||
required={true}
|
||||
bind:value={reporter}
|
||||
/>
|
||||
<Search
|
||||
size="sm"
|
||||
suggestionRequired={true}
|
||||
emptyAllowed={true}
|
||||
searchSuggestionFunc={usernameSuggestions}
|
||||
invalidMessage="Es können nur registrierte Spieler reportet werden"
|
||||
label="Reporteter User"
|
||||
bind:value={reported}
|
||||
/>
|
||||
</div>
|
||||
<div class="divider mx-4 pt-3"></div>
|
||||
<Input type="text" bind:value={reason} required={true} pickyWidth={false}>
|
||||
{#snippet label()}
|
||||
<span>Report Grund</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<div>
|
||||
<Textarea rows={4} label="Details über den Report Grund" bind:value={body} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row space-x-2 mt-6">
|
||||
<Input
|
||||
type="submit"
|
||||
value="Erstellen"
|
||||
onclick={(e) => {
|
||||
if (reportForm.checkValidity()) {
|
||||
e.preventDefault();
|
||||
showPopupModal({
|
||||
title: 'Report Erstellen?',
|
||||
text: body
|
||||
? 'Dadurch, dass bereits Details über den Report Grund hinzugefügt wurden, ist es nach dem Erstellen nicht mehr möglich, den Report Inhalt zu ändern'
|
||||
: 'Der Report wird als Entwurf gespeichert und kann nach dem Erstellen über den Report-Link bearbeitet werden',
|
||||
actions: [
|
||||
{
|
||||
text: 'Erstellen',
|
||||
action: async () => {
|
||||
await newReport();
|
||||
globalCloseForm.submit();
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Abbrechen"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
globalCloseForm.submit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
|
||||
<button>close</button>
|
||||
</form>
|
30
src/routes/admin/reports/schema.ts
Normal file
30
src/routes/admin/reports/schema.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReportListSchema = z.object({
|
||||
limit: z.number().nullish(),
|
||||
from: z.number().nullish(),
|
||||
|
||||
status: z.enum(['none', 'review', 'reviewed']).nullish(),
|
||||
reporter: z.string().nullish(),
|
||||
reported: z.string().nullish(),
|
||||
draft: z.boolean().nullish(),
|
||||
|
||||
hash: z.string().nullish()
|
||||
});
|
||||
|
||||
export const ReportEditSchema = z.object({
|
||||
id: z.number(),
|
||||
reported: z.string().nullish(),
|
||||
auditor: z.number(),
|
||||
notice: z.string().nullish(),
|
||||
statement: z.string().nullish(),
|
||||
status: z.enum(['none', 'review', 'reviewed']).nullish(),
|
||||
strike_reason: z.number().nullish()
|
||||
});
|
||||
|
||||
export const ReportAddSchema = z.object({
|
||||
reporter: z.string(),
|
||||
reported: z.string().nullish(),
|
||||
reason: z.string(),
|
||||
body: z.string().nullish()
|
||||
});
|
33
src/routes/admin/settings/+page.server.ts
Normal file
33
src/routes/admin/settings/+page.server.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Settings } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
|
||||
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
|
||||
}
|
||||
|
||||
const { self } = await parent();
|
||||
|
||||
const settings = (await Settings.findAll()).reduce(
|
||||
(prev, curr) => {
|
||||
return { ...prev, [curr.key]: curr.value };
|
||||
},
|
||||
{} as { [key: string]: any }
|
||||
);
|
||||
|
||||
return {
|
||||
settings: {
|
||||
global: {},
|
||||
register: {
|
||||
enabled: settings['register.enabled'] ?? true,
|
||||
disabled_title: settings['register.disabled_title'] ?? 'Anmeldung geschlossen',
|
||||
disabled_details: settings['register.disabled_details'] ?? ''
|
||||
}
|
||||
},
|
||||
self: self
|
||||
};
|
||||
};
|
76
src/routes/admin/settings/+page.svelte
Normal file
76
src/routes/admin/settings/+page.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let settings = $state($state.snapshot(data.settings));
|
||||
|
||||
async function change() {
|
||||
await fetch(`${env.PUBLIC_BASE_PATH}/admin/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
global: {},
|
||||
register: {
|
||||
enabled: returnIfNoDup(settings.register.enabled, data.settings.register.enabled),
|
||||
disabled_title: returnIfNoDup(
|
||||
settings.register.disabled_title,
|
||||
data.settings.register.disabled_title
|
||||
),
|
||||
disabled_details: returnIfNoDup(
|
||||
settings.register.disabled_details,
|
||||
data.settings.register.disabled_details
|
||||
)
|
||||
}
|
||||
} as PageData['settings'])
|
||||
});
|
||||
data.settings = settings;
|
||||
settings = $state.snapshot(data.settings);
|
||||
}
|
||||
|
||||
function returnIfNoDup<T>(value: T, original: T): T | undefined {
|
||||
return value != original ? value : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col items-center justify-between">
|
||||
<div class="grid grid-cols-3 w-full [&>*]:mx-8">
|
||||
<!--div>
|
||||
<div class="divider">Global</div>
|
||||
<label class="label">
|
||||
<span class="label-text">PayPal-Spendenlink</span>
|
||||
<input type="text" class="input input-bordered" bind:value={settings.global.paypal_link} />
|
||||
</label>
|
||||
</div-->
|
||||
<div>
|
||||
<div class="divider">Anmeldung</div>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Aktiviert</span>
|
||||
<input type="checkbox" class="toggle" bind:checked={settings.register.enabled} />
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="label-text">Text wenn die Anmeldung deaktiviert ist</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
bind:value={settings.register.disabled_title}
|
||||
/>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="label-text">Sub-Text wenn die Anmeldung deaktiviert ist</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
bind:value={settings.register.disabled_details}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<button
|
||||
class="btn btn-success mt-auto"
|
||||
class:btn-disabled={JSON.stringify(data.settings) === JSON.stringify(settings)}
|
||||
onclick={change}>Speichern</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
32
src/routes/admin/settings/+server.ts
Normal file
32
src/routes/admin/settings/+server.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { PageData } from './$types';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { Settings } from '$lib/server/database';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const settings: PageData['settings'] = await request.json();
|
||||
|
||||
for (const [group, entries] of Object.entries(settings)) {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
const setting = await Settings.findOne({ where: { key: `${group}.${key}` } });
|
||||
if (setting) {
|
||||
setting.value = JSON.stringify(value);
|
||||
await setting.save();
|
||||
} else {
|
||||
await Settings.create({
|
||||
key: `${group}.${key}`,
|
||||
value: JSON.stringify(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
3
src/routes/admin/tools/+layout.svelte
Normal file
3
src/routes/admin/tools/+layout.svelte
Normal file
@ -0,0 +1,3 @@
|
||||
<div class="flex justify-center">
|
||||
<slot />
|
||||
</div>
|
11
src/routes/admin/tools/+page.server.ts
Normal file
11
src/routes/admin/tools/+page.server.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
|
||||
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
|
||||
}
|
||||
};
|
15
src/routes/admin/tools/+page.svelte
Normal file
15
src/routes/admin/tools/+page.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import UuidFinder from './UuidFinder.svelte';
|
||||
|
||||
let tools = [{ label: 'Account UUID finder', component: UuidFinder }];
|
||||
</script>
|
||||
|
||||
<div class="mt-4">
|
||||
{#each tools as tool}
|
||||
{@const Component = tool.component}
|
||||
<fieldset class="border border-solid rounded border-gray-700 py-3 px-6">
|
||||
<legend class="text-sm px-1">{tool.label}</legend>
|
||||
<Component />
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
71
src/routes/admin/tools/+server.ts
Normal file
71
src/routes/admin/tools/+server.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import {
|
||||
ApiError,
|
||||
getBedrockUuid,
|
||||
getJavaUuid,
|
||||
RateLimitError,
|
||||
UserNotFoundError
|
||||
} from '$lib/server/minecraft';
|
||||
import type { ZodType } from 'zod';
|
||||
import { FindUuidSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ url, request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Tools] }) == null) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const action = url.searchParams.get('action');
|
||||
if (!action) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'getuuid': {
|
||||
const data = await parseData(FindUuidSchema, await request.json());
|
||||
return new Response(await findUuid(data.username, data.edition), { status: 200 });
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 400 });
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error }), { status: 400 });
|
||||
}
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
async function parseData<Output>(schema: ZodType<Output>, json: string): Promise<Output> {
|
||||
const parseResult = await schema.safeParseAsync(json);
|
||||
if (!parseResult.success) throw new Error(parseResult.error.toString());
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
async function findUuid(username: string, edition: 'java' | 'bedrock'): Promise<string> {
|
||||
let uuid = '';
|
||||
|
||||
try {
|
||||
switch (edition) {
|
||||
case 'java':
|
||||
uuid = await getJavaUuid(username);
|
||||
break;
|
||||
case 'bedrock':
|
||||
uuid = await getBedrockUuid(username);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof UserNotFoundError) {
|
||||
throw `Der Spielername ${username} existiert nicht`;
|
||||
} else if (e instanceof ApiError) {
|
||||
throw (e as Error).message;
|
||||
} else if (e instanceof RateLimitError) {
|
||||
throw 'Rate limit exceeded, bitte versuche es erneut';
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
uuid: uuid
|
||||
});
|
||||
}
|
44
src/routes/admin/tools/UuidFinder.svelte
Normal file
44
src/routes/admin/tools/UuidFinder.svelte
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import { sendRequest } from './tools';
|
||||
|
||||
let username = $state('');
|
||||
let edition = $state('java');
|
||||
let uuid = $state('');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="flex flex-row space-x-2">
|
||||
<Input type="text" size="sm" bind:value={username}>
|
||||
{#snippet label()}
|
||||
<span>Username</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Select label="Edition" size="sm" bind:value={edition}>
|
||||
<option value="java">Java Edition</option>
|
||||
<option value="bedrock">Bedrock Edition</option>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!username}
|
||||
value="UUID finden"
|
||||
onclick={() =>
|
||||
sendRequest('getuuid', { username, edition })
|
||||
.then((data) => (uuid = data.uuid))
|
||||
.catch(() => (uuid = ''))}
|
||||
/>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
type="text"
|
||||
size="sm"
|
||||
readonly={true}
|
||||
disabled={!uuid}
|
||||
pickyWidth={false}
|
||||
placeholder="UUID... "
|
||||
value={uuid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
6
src/routes/admin/tools/schema.ts
Normal file
6
src/routes/admin/tools/schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FindUuidSchema = z.object({
|
||||
username: z.string(),
|
||||
edition: z.enum(['java', 'bedrock'])
|
||||
});
|
22
src/routes/admin/tools/tools.ts
Normal file
22
src/routes/admin/tools/tools.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { errorMessage } from '$lib/stores';
|
||||
|
||||
type Actions = 'getuuid';
|
||||
|
||||
export async function sendRequest<T = any>(action: Actions, data: any): Promise<T> {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/tools?action=${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
if (Object.hasOwn(data, 'error')) {
|
||||
errorMessage.set(data['error']);
|
||||
} else {
|
||||
errorMessage.set('Ein Fehler ist aufgetreten');
|
||||
}
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
@ -11,6 +11,6 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
|
||||
|
||||
return {
|
||||
count:
|
||||
getSession(cookies, { permissions: [Permissions.UserRead] }) != null ? await User.count() : 0
|
||||
getSession(cookies, { permissions: [Permissions.Users] }) != null ? await User.count() : 0
|
||||
};
|
||||
};
|
||||
|
@ -1,112 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { IconOutline, IconSolid } from 'svelte-heros-v2';
|
||||
import { Check, NoSymbol, PencilSquare, Plus, Trash } from 'svelte-heros-v2';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { User } from '$lib/server/database';
|
||||
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
|
||||
import { buttonTriggeredRequest } from '$lib/components/utils';
|
||||
import { browser } from '$app/environment';
|
||||
import HeaderBar from './HeaderBar.svelte';
|
||||
import SortableTr from '$lib/components/Table/SortableTr.svelte';
|
||||
import SortableTh from '$lib/components/Table/SortableTh.svelte';
|
||||
import NewUserModal from './NewUserModal.svelte';
|
||||
import PaginationTableBody from '$lib/components/PaginationTable/PaginationTableBody.svelte';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
export let data: PageData;
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let headers = [
|
||||
{
|
||||
name: 'Vorname',
|
||||
key: 'firstname',
|
||||
asc: false
|
||||
},
|
||||
{
|
||||
name: 'Nachname',
|
||||
key: 'lastname',
|
||||
asc: false
|
||||
},
|
||||
{ name: 'Geburtstag', key: 'birthday', asc: false, sort: (a, b) => a.birthday - b.birthday },
|
||||
{ name: 'Telefon', key: 'telephone', asc: false, sort: (a, b) => a.telephone - b.telephone },
|
||||
{
|
||||
name: 'Username',
|
||||
key: 'username',
|
||||
asc: false
|
||||
},
|
||||
{
|
||||
name: 'Minecraft Edition',
|
||||
key: 'playertype',
|
||||
asc: false
|
||||
},
|
||||
{
|
||||
name: 'Passwort',
|
||||
key: 'password',
|
||||
asc: false
|
||||
},
|
||||
{ name: 'UUID', key: 'uuid', asc: false }
|
||||
];
|
||||
let ascHeader: (typeof headers)[0] | null = null;
|
||||
let users: (typeof User.prototype.dataValues)[] = $state([]);
|
||||
let usersPerRequest = 25;
|
||||
let userFilter: { [k: string]: any } = $state({ name: null, playertype: null });
|
||||
|
||||
let currentPageUsers: (typeof User.prototype.dataValues)[] = [];
|
||||
let currentPageUsersRequest: Promise<void> = new Promise((resolve) => resolve());
|
||||
let usersCache: (typeof User.prototype.dataValues)[][] = [];
|
||||
let usersPerPage = 50;
|
||||
let userPage = 0;
|
||||
let userTableContainerElement: HTMLDivElement;
|
||||
let newUserModal: HTMLDialogElement;
|
||||
|
||||
function fetchPageUsers(page: number) {
|
||||
if (!browser) return;
|
||||
async function fetchUsers(extendedFilter?: {
|
||||
limit?: number;
|
||||
from?: number;
|
||||
}): Promise<typeof users> {
|
||||
if (!browser) return [];
|
||||
|
||||
if (userTableContainerElement) userTableContainerElement.scrollTop = 0;
|
||||
|
||||
if (usersCache[page]) {
|
||||
currentPageUsers = usersCache[page];
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
currentPageUsersRequest = new Promise(async (resolve, reject) => {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
limit: usersPerPage,
|
||||
from: usersPerPage * page
|
||||
...userFilter,
|
||||
limit: extendedFilter?.limit ?? usersPerRequest,
|
||||
from: extendedFilter?.from ?? users.length
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
const pageUsers = await response.json();
|
||||
currentPageUsers = usersCache[page] = pageUsers;
|
||||
resolve();
|
||||
} else {
|
||||
reject(Error());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sortUsers(key: string, reverse: boolean) {
|
||||
const multiplyValue = reverse ? -1 : 1;
|
||||
|
||||
currentPageUsers.sort((entryA, entryB) => {
|
||||
const a = entryA[key];
|
||||
const b = entryB[key];
|
||||
|
||||
switch (typeof a) {
|
||||
case 'number':
|
||||
return (a - b) * multiplyValue;
|
||||
case 'string':
|
||||
return a.localeCompare(b) * multiplyValue;
|
||||
default:
|
||||
return (a - b) * multiplyValue;
|
||||
return await response.json();
|
||||
}
|
||||
});
|
||||
currentPageUsers = currentPageUsers;
|
||||
}
|
||||
|
||||
$: fetchPageUsers(userPage);
|
||||
|
||||
async function updateUser(user: typeof User.prototype.dataValues) {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
|
||||
await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(user)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
@ -117,213 +57,202 @@
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
currentPageUsers.splice(
|
||||
currentPageUsers.findIndex((v) => v.id == id),
|
||||
users.splice(
|
||||
users.findIndex((v) => v.id == id),
|
||||
1
|
||||
);
|
||||
currentPageUsers = currentPageUsers;
|
||||
} else {
|
||||
throw new Error();
|
||||
users = users;
|
||||
}
|
||||
}
|
||||
|
||||
let userFilterEffectAlreadyRan = false;
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
userFilter;
|
||||
|
||||
if (!userFilterEffectAlreadyRan) {
|
||||
userFilterEffectAlreadyRan = true;
|
||||
return;
|
||||
}
|
||||
fetchUsers({ from: 0 }).then((u) => (users = u));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="h-[90vh] overflow-scroll" bind:this={userTableContainerElement}>
|
||||
<table class="table relative">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="grid grid-cols-[10fr_1fr_10fr_1fr_10fr]">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<HeaderBar bind:userFilter onUpdate={() => (userFilter = $state.snapshot(userFilter))} />
|
||||
<div class="divider divider-horizontal my-auto h-3/4"></div>
|
||||
<div class="flex items-center">
|
||||
<button class="btn" onclick={() => newUserModal.show()}>
|
||||
<Plus />
|
||||
<span>Neuer Spieler</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider my-1 mx-8 border-none" />
|
||||
<div class="h-full overflow-scroll" bind:this={userTableContainerElement}>
|
||||
<table class="table table-auto">
|
||||
<thead>
|
||||
<tr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
|
||||
<th />
|
||||
{#each headers as header}
|
||||
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<button
|
||||
class="flex items-center"
|
||||
on:click={() => {
|
||||
sortUsers(header.key, (ascHeader = ascHeader == header ? null : header));
|
||||
<!-- prettier-ignore -->
|
||||
<SortableTr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
|
||||
<th></th>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'firstname', asc: e.asc}}}>Vorname</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'lastname', asc: e.asc}}}>Nachname</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'birthday', asc: e.asc}}}>Geburtstag</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'telephone', asc: e.asc}}}>Telefon</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'username', asc: e.asc}}}>Username</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'playertype', asc: e.asc}}}>Minecraft Edition</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'password', asc: e.asc}}}>Passwort</SortableTh>
|
||||
<SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'uuid', asc: e.asc}}}>UUID</SortableTh>
|
||||
<th></th>
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<PaginationTableBody
|
||||
onUpdate={async () => {
|
||||
await fetchUsers().then((u) => (users = [...users, ...u]));
|
||||
}}
|
||||
>
|
||||
{header.name}
|
||||
<span class="ml-1">
|
||||
<IconSolid
|
||||
name={ascHeader === header ? 'chevron-up-solid' : 'chevron-down-solid'}
|
||||
width="12"
|
||||
height="12"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
{/each}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#key currentPageUsersRequest}
|
||||
{#await currentPageUsersRequest}
|
||||
{#each Array(usersPerPage) as _, i}
|
||||
<tr class="animate-pulse text-transparent">
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="date" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="tel" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Select id="edition" disabled={true} size="sm">
|
||||
<option value="java">Java Edition</option>
|
||||
<option value="bedrock">Bedrock Edition</option>
|
||||
<option value="cracked">Java cracked</option>
|
||||
</Select></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><Input type="text" disabled={true} size="sm" /></td
|
||||
>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
|
||||
><div class="flex gap-1">
|
||||
<button class="btn btn-sm btn-square" disabled />
|
||||
</div></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{:then _}
|
||||
{#each currentPageUsers as user, i}
|
||||
{#each users as user, i}
|
||||
<tr>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Input
|
||||
type="date"
|
||||
value={new Date(user.birthday).toISOString().split('T')[0]}
|
||||
on:input={(e) => (user.birthday = e.detail.target.valueAsDate.toISOString())}
|
||||
oninput={(e) => (user.birthday = e.currentTarget.valueAsDate.toISOString())}
|
||||
disabled={!user.edit}
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Select id="edition" bind:value={user.playertype} disabled={!user.edit} size="sm">
|
||||
<option value="java">Java Edition</option>
|
||||
<option value="bedrock">Bedrock Edition</option>
|
||||
<option value="cracked">Java cracked</option>
|
||||
<option value="noauth">Java noauth</option>
|
||||
</Select>
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<Input
|
||||
id="uuid"
|
||||
type="text"
|
||||
bind:value={user.uuid}
|
||||
disabled={!user.edit}
|
||||
size="sm"
|
||||
/>
|
||||
<td>
|
||||
<Input id="uuid" type="text" bind:value={user.uuid} disabled={!user.edit} size="sm" />
|
||||
</td>
|
||||
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
{#if user.edit}
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
on:click={async (e) => {
|
||||
onclick={async (e) => {
|
||||
showPopupModal({
|
||||
title: 'Speichern',
|
||||
text: `Sollen die Änderungen für den Nutzer '${user.username}' gespeichert werden?`,
|
||||
actions: [
|
||||
{
|
||||
text: 'Speichern',
|
||||
action: async () => {
|
||||
await buttonTriggeredRequest(e, updateUser(user));
|
||||
user.edit = false;
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="check-outline" width="18" height="18" />
|
||||
<Check size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (
|
||||
user.firstname === user.before.firstname &&
|
||||
user.lastname === user.before.lastname &&
|
||||
user.birthday === user.before.birthday &&
|
||||
user.telephone === user.before.telephone &&
|
||||
user.username === user.before.username &&
|
||||
user.playertype === user.before.playertype &&
|
||||
user.password === user.before.password &&
|
||||
user.uuid === user.before.uuid
|
||||
) {
|
||||
user.edit = false;
|
||||
user = user.before;
|
||||
return;
|
||||
}
|
||||
|
||||
showPopupModal({
|
||||
title: 'Abbrechen',
|
||||
text: 'Soll die Nutzerbearbeitung abgebrochen werden?',
|
||||
actions: [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
action: () => {
|
||||
user.edit = false;
|
||||
users[i] = user.before;
|
||||
}
|
||||
},
|
||||
{ text: 'Schließen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="no-symbol-outline" width="18" height="18" />
|
||||
<NoSymbol size="18" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
on:click={() => {
|
||||
user.before = structuredClone(user);
|
||||
onclick={() => {
|
||||
user.before = $state.snapshot(user);
|
||||
user.edit = true;
|
||||
}}
|
||||
>
|
||||
<IconOutline name="pencil-square-outline" width="18" height="18" />
|
||||
<PencilSquare size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-square"
|
||||
on:click={(e) => buttonTriggeredRequest(e, deleteUser(user.id))}
|
||||
onclick={(e) => {
|
||||
showPopupModal({
|
||||
title: 'Nutzer löschen',
|
||||
text: `Soll der Nutzer '${user.username}' wirklich gelöscht werden?`,
|
||||
actions: [
|
||||
{
|
||||
text: 'Löschen',
|
||||
action: () => buttonTriggeredRequest(e, deleteUser(user.id))
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconOutline name="trash-outline" width="18" height="18" />
|
||||
<Trash size="18" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/await}
|
||||
{/key}
|
||||
</tbody>
|
||||
</PaginationTableBody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex justify-center w-full mt-4 mb-6">
|
||||
<div class="join">
|
||||
{#each Array(Math.ceil(data.count / usersPerPage) || 1) as _, i}
|
||||
<button
|
||||
class="join-item btn"
|
||||
class:btn-active={i === userPage}
|
||||
on:click={() => {
|
||||
userPage = i;
|
||||
}}>{i + 1}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
thead tr th,
|
||||
tbody tr td {
|
||||
@apply relative;
|
||||
|
||||
&:not(:first-child) {
|
||||
@apply border-l-[1px] border-dashed;
|
||||
|
||||
&::before {
|
||||
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
@apply border-r-[1px] border-dashed border-base-300;
|
||||
|
||||
&::after {
|
||||
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<dialog class="modal" bind:this={newUserModal}>
|
||||
<NewUserModal
|
||||
onSubmit={(e) => {
|
||||
users = [...users, e];
|
||||
newUserModal.close();
|
||||
}}
|
||||
/>
|
||||
</dialog>
|
||||
|
@ -1,77 +1,179 @@
|
||||
import { getSession } from '$lib/server/session';
|
||||
import { Permissions } from '$lib/permissions';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Admin, User } from '$lib/server/database';
|
||||
import { error, type RequestHandler } from '@sveltejs/kit';
|
||||
import { User } from '$lib/server/database';
|
||||
import { type Attributes, Op } from 'sequelize';
|
||||
import {
|
||||
ApiError,
|
||||
getJavaUuid,
|
||||
getNoAuthUuid,
|
||||
RateLimitError,
|
||||
UserNotFoundError
|
||||
} from '$lib/server/minecraft';
|
||||
import { UserAddSchema, UserDeleteSchema, UserEditSchema, UserListSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) {
|
||||
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const limit = data['limit'] || 100;
|
||||
const from = data['from'] || 0;
|
||||
const parseResult = await UserListSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const users = await User.findAll({ offset: from, limit: limit });
|
||||
const usersFindOptions: Attributes<User> = {};
|
||||
if (data.name) {
|
||||
Object.assign(usersFindOptions, {
|
||||
[Op.or]: {
|
||||
firstname: { [Op.like]: `%${data.name}%` },
|
||||
lastname: { [Op.like]: `%${data.name}%` },
|
||||
username: { [Op.like]: `%${data.name}%` }
|
||||
}
|
||||
});
|
||||
} else if (data.search) {
|
||||
Object.assign(usersFindOptions, {
|
||||
[Op.or]: {
|
||||
username: { [Op.like]: `%${data.search}%` },
|
||||
uuid: { [Op.like]: `%${data.search}%` }
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.playertype) {
|
||||
usersFindOptions.playertype = data.playertype;
|
||||
}
|
||||
const users = await User.findAll({
|
||||
where: usersFindOptions,
|
||||
attributes: data.slim ? ['username', 'uuid'] : undefined,
|
||||
offset: data.from || 0,
|
||||
limit: data.limit || 100,
|
||||
order: data.sort ? [[data.sort.key, data.sort.asc ? 'ASC' : 'DESC']] : undefined
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(users));
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const PATCH = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) {
|
||||
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const id = data['id'] as string | null;
|
||||
|
||||
if (id == null) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
const parseResult = await UserEditSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const user = await User.findOne({ where: { id: id } });
|
||||
const user = await User.findOne({ where: { id: data.id } });
|
||||
if (!user) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
if (data['firstname']) user.firstname = data['firstname'];
|
||||
if (data['lastname']) user.lastname = data['lastname'];
|
||||
if (data['birthday']) user.birthday = data['birthday'];
|
||||
if (data['telephone']) user.telephone = data['telephone'];
|
||||
if (data['username']) user.username = data['username'];
|
||||
if (data['playertype']) user.playertype = data['playertype'];
|
||||
if (data['password']) user.password = data['password'];
|
||||
if (data['uuid']) user.uuid = data['uuid'];
|
||||
if (data.firstname) user.firstname = data.firstname;
|
||||
if (data.lastname) user.lastname = data.lastname;
|
||||
if (data.birthday) user.birthday = data.birthday;
|
||||
if (data.telephone) user.telephone = data.telephone;
|
||||
if (data.username) user.username = data.username;
|
||||
if (data.playertype) user.playertype = data.playertype;
|
||||
if (data.uuid) user.uuid = data.uuid;
|
||||
await user.save();
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const DELETE = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) {
|
||||
export const PUT = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const id = (data['id'] as number) || null;
|
||||
const parseResult = await UserAddSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
if (id == null) {
|
||||
return new Response(null, {
|
||||
status: 400
|
||||
});
|
||||
let uuid: string | null;
|
||||
try {
|
||||
switch (data.playertype) {
|
||||
case 'java':
|
||||
uuid = await getJavaUuid(data.username);
|
||||
break;
|
||||
case 'bedrock':
|
||||
uuid = null;
|
||||
// uuid = await getBedrockUuid(username);
|
||||
break;
|
||||
case 'noauth':
|
||||
uuid = getNoAuthUuid(data.username);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`invalid player type (${data.playertype})`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof UserNotFoundError) {
|
||||
throw error(400, `Der Spielername ${data.username} existiert nicht`);
|
||||
} else if (e instanceof ApiError) {
|
||||
console.error((e as Error).message);
|
||||
uuid = null;
|
||||
} else if (e instanceof RateLimitError) {
|
||||
console.error(`uuid request rate limited for user '${data.username}'`);
|
||||
uuid = null;
|
||||
} else {
|
||||
console.error((e as Error).message);
|
||||
throw error(500);
|
||||
}
|
||||
}
|
||||
|
||||
await User.destroy({ where: { id: id } });
|
||||
if (uuid && (await User.findOne({ where: { uuid: uuid } }))) {
|
||||
throw error(400, 'Dieser Minecraft-Account wurde bereits registriert');
|
||||
} else if (
|
||||
await User.findOne({
|
||||
where: {
|
||||
firstname: data.firstname,
|
||||
lastname: data.lastname,
|
||||
birthday: new Date(data.birthday).toUTCString()
|
||||
}
|
||||
})
|
||||
) {
|
||||
throw error(400, 'Ein Nutzer mit demselben Namen und Geburtstag wurde bereits registriert');
|
||||
}
|
||||
|
||||
await User.create({
|
||||
firstname: data.firstname,
|
||||
lastname: data.lastname,
|
||||
birthday: new Date(data.birthday).toUTCString(),
|
||||
telephone: data.telephone,
|
||||
username: data.username,
|
||||
playertype: data.playertype,
|
||||
password: null,
|
||||
uuid: uuid
|
||||
});
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const DELETE = (async ({ request, cookies }) => {
|
||||
if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
|
||||
return new Response(null, {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await UserDeleteSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
await User.destroy({ where: { id: data.id } });
|
||||
|
||||
return new Response();
|
||||
}) satisfies RequestHandler;
|
||||
|
39
src/routes/admin/users/HeaderBar.svelte
Normal file
39
src/routes/admin/users/HeaderBar.svelte
Normal file
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
|
||||
let {
|
||||
userFilter = $bindable({ name: null, playertype: null }),
|
||||
onUpdate
|
||||
}: { userFilter: { [k: string]: any }; onUpdate: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<form class="flex flex-row justify-center items-center space-x-4 my-2 w-full">
|
||||
<div class="w-full">
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="..."
|
||||
bind:value={userFilter.name}
|
||||
pickyWidth={false}
|
||||
oninput={onUpdate}
|
||||
>
|
||||
{#snippet label()}
|
||||
<span>Username</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<Select
|
||||
label="Edition"
|
||||
size="sm"
|
||||
bind:value={userFilter.playertype}
|
||||
pickyWidth={false}
|
||||
onChange={onUpdate}
|
||||
>
|
||||
<option value={null}>Alle</option>
|
||||
<option value="java">Java</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="noauth">Noauth</option>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
135
src/routes/admin/users/NewUserModal.svelte
Normal file
135
src/routes/admin/users/NewUserModal.svelte
Normal file
@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import { errorMessage } from '$lib/stores';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
let {
|
||||
onSubmit
|
||||
}: {
|
||||
onSubmit: ({
|
||||
firstname,
|
||||
lastname,
|
||||
birthday,
|
||||
telephone,
|
||||
username,
|
||||
playertype
|
||||
}: {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
birthday: string;
|
||||
telephone: string;
|
||||
username: string;
|
||||
playertype: string;
|
||||
}) => void;
|
||||
} = $props();
|
||||
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let firstname: string = $state('');
|
||||
let lastname: string = $state('');
|
||||
let birthday: string = $state('');
|
||||
let phone: string = $state('');
|
||||
let username: string = $state('');
|
||||
let playertype = $state('java');
|
||||
|
||||
async function newUser() {
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
firstname: firstname,
|
||||
lastname: lastname,
|
||||
birthday: birthday,
|
||||
telephone: phone,
|
||||
username: username,
|
||||
playertype: playertype
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
onSubmit({
|
||||
firstname: firstname,
|
||||
lastname: lastname,
|
||||
birthday: birthday,
|
||||
telephone: phone,
|
||||
username: username,
|
||||
playertype: playertype
|
||||
});
|
||||
globalCloseForm.submit();
|
||||
} else {
|
||||
$errorMessage = (await response.json()).message;
|
||||
}
|
||||
}
|
||||
|
||||
let globalCloseForm: HTMLFormElement;
|
||||
|
||||
let reportForm: HTMLFormElement;
|
||||
</script>
|
||||
|
||||
<form method="dialog" class="modal-box" bind:this={reportForm}>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
globalCloseForm.submit();
|
||||
}}>✕</button
|
||||
>
|
||||
<h3 class="font-roboto text-3xl">Neuer Spieler</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input type="text" required bind:value={firstname}>
|
||||
{#snippet label()}
|
||||
<span>Vorname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input type="text" required bind:value={lastname}>
|
||||
{#snippet label()}
|
||||
<span>Nachname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input type="date" required bind:value={birthday}>
|
||||
{#snippet label()}
|
||||
<span>Geburtstag</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input type="tel" bind:value={phone}>
|
||||
{#snippet label()}
|
||||
<span>Telefonnummer</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input type="text" required bind:value={username}>
|
||||
{#snippet label()}
|
||||
<span>Minecraft-Spielername</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Select required label="Edition" bind:value={playertype}>
|
||||
<option value="java">Java Edition</option>
|
||||
<option value="bedrock">Bedrock Edition</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-row space-x-2 mt-6">
|
||||
<Input
|
||||
type="submit"
|
||||
value="Hinzufügen"
|
||||
onclick={(e) => {
|
||||
if (reportForm.checkValidity()) {
|
||||
e.preventDefault();
|
||||
showPopupModal({
|
||||
title: 'Spieler hinzufügen?',
|
||||
actions: [{ text: 'Hinzufügen', action: newUser }, { text: 'Abbrechen' }]
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Abbrechen"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
globalCloseForm.submit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
|
||||
<button>close</button>
|
||||
</form>
|
52
src/routes/admin/users/schema.ts
Normal file
52
src/routes/admin/users/schema.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UserListSchema = z.object({
|
||||
limit: z.number().nullish(),
|
||||
from: z.number().nullish(),
|
||||
|
||||
name: z.string().nullish(),
|
||||
playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(),
|
||||
|
||||
search: z.string().nullish(),
|
||||
slim: z.boolean().nullish(),
|
||||
|
||||
sort: z
|
||||
.object({
|
||||
key: z.enum([
|
||||
'firstname',
|
||||
'lastname',
|
||||
'birthday',
|
||||
'telephone',
|
||||
'username',
|
||||
'playertype',
|
||||
'password',
|
||||
'uuid'
|
||||
]),
|
||||
asc: z.boolean()
|
||||
})
|
||||
.nullish()
|
||||
});
|
||||
|
||||
export const UserEditSchema = z.object({
|
||||
id: z.number(),
|
||||
firstname: z.string().nullish(),
|
||||
lastname: z.string().nullish(),
|
||||
birthday: z.coerce.date().nullish(),
|
||||
telephone: z.string().nullish(),
|
||||
username: z.string().nullish(),
|
||||
playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(),
|
||||
uuid: z.string().nullish()
|
||||
});
|
||||
|
||||
export const UserAddSchema = z.object({
|
||||
firstname: z.string(),
|
||||
lastname: z.string(),
|
||||
birthday: z.coerce.date(),
|
||||
telephone: z.string().nullish(),
|
||||
username: z.string(),
|
||||
playertype: z.enum(['java', 'bedrock', 'noauth'])
|
||||
});
|
||||
|
||||
export const UserDeleteSchema = z.object({
|
||||
id: z.number()
|
||||
});
|
49
src/routes/api/feedback/+server.ts
Normal file
49
src/routes/api/feedback/+server.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { FeedbackAddSchema } from './schema';
|
||||
import { Feedback, User } from '$lib/server/database';
|
||||
import crypto from 'crypto';
|
||||
import type { CreationAttributes } from 'sequelize';
|
||||
import { env as public_env } from '$env/dynamic/public';
|
||||
|
||||
export const POST = (async ({ request, url }) => {
|
||||
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
|
||||
return new Response(null, { status: 401 });
|
||||
|
||||
const parseResult = await FeedbackAddSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const feedback = {} as { [k: string]: CreationAttributes<Feedback> };
|
||||
for (const user of await User.findAll({
|
||||
where: { uuid: data.users },
|
||||
attributes: ['id', 'uuid']
|
||||
})) {
|
||||
feedback[user.uuid!] = {
|
||||
url_hash: crypto.randomBytes(18).toString('hex'),
|
||||
event: data.event,
|
||||
title: data.title ?? null,
|
||||
draft: true,
|
||||
user_id: user.id
|
||||
};
|
||||
}
|
||||
|
||||
await Feedback.bulkCreate(Object.values(feedback));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
Object.entries(feedback).reduce(
|
||||
(curr, [k, v]) => {
|
||||
curr[k] = `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/feedback/${
|
||||
v.url_hash
|
||||
}`;
|
||||
return curr;
|
||||
},
|
||||
{} as { [k: string]: string }
|
||||
)
|
||||
),
|
||||
{ status: 201 }
|
||||
);
|
||||
}) satisfies RequestHandler;
|
7
src/routes/api/feedback/schema.ts
Normal file
7
src/routes/api/feedback/schema.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FeedbackAddSchema = z.object({
|
||||
event: z.string(),
|
||||
title: z.string().nullish(),
|
||||
users: z.array(z.string())
|
||||
});
|
95
src/routes/api/report/+server.ts
Normal file
95
src/routes/api/report/+server.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Report, User } from '$lib/server/database';
|
||||
import * as crypto from 'crypto';
|
||||
import { env as public_env } from '$env/dynamic/public';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { ReportAddSchema } from './schema';
|
||||
|
||||
export const GET = (async ({ url }) => {
|
||||
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
|
||||
return new Response(null, { status: 401 });
|
||||
|
||||
const user = await User.findOne({ where: { uuid: url.searchParams.get('uuid') ?? '' } });
|
||||
if (user === null) return new Response(null, { status: 400 });
|
||||
|
||||
const reports = {
|
||||
from_self: await Report.findAll({
|
||||
where: { reporter_id: user.id },
|
||||
include: [{ model: User, as: 'reported' }]
|
||||
}).then((reports) =>
|
||||
reports.map((report) => {
|
||||
return {
|
||||
reported: report.reported
|
||||
? {
|
||||
username: report.reported.username,
|
||||
uuid: report.reported.uuid
|
||||
}
|
||||
: null,
|
||||
subject: report.subject,
|
||||
draft: report.draft,
|
||||
status: report.status,
|
||||
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}`
|
||||
};
|
||||
})
|
||||
),
|
||||
to_self: await Report.findAll({
|
||||
where: { reported_id: user.id },
|
||||
include: [{ model: User, as: 'reporter' }]
|
||||
}).then((reports) =>
|
||||
reports.map((report) => {
|
||||
return {
|
||||
reporter: {
|
||||
username: report.reporter.username,
|
||||
uuid: report.reporter.uuid
|
||||
},
|
||||
subject: report.subject,
|
||||
draft: report.draft,
|
||||
status: report.status,
|
||||
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}`
|
||||
};
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(reports), { status: 200 });
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
export const POST = (async ({ request, url }) => {
|
||||
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
|
||||
return new Response(null, { status: 401 });
|
||||
|
||||
const parseResult = await ReportAddSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
const reporter = await User.findOne({ where: { uuid: data.reporter } });
|
||||
const reported = data.reported
|
||||
? await User.findOne({ where: { uuid: data.reported } })
|
||||
: undefined;
|
||||
|
||||
if (reporter == null || reported === null) return new Response(null, { status: 400 });
|
||||
|
||||
const report = await Report.create({
|
||||
subject: data.reason,
|
||||
body: null,
|
||||
draft: true,
|
||||
status: 'none',
|
||||
url_hash: crypto.randomBytes(18).toString('hex'),
|
||||
completed: false,
|
||||
reporter_id: reporter.id,
|
||||
reported_id: reported?.id || null
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
url: `${url.protocol}//${url.host}${public_env.PUBLIC_BASE_PATH || ''}/report/${
|
||||
report.url_hash
|
||||
}`
|
||||
}),
|
||||
{
|
||||
status: 201
|
||||
}
|
||||
);
|
||||
}) satisfies RequestHandler;
|
7
src/routes/api/report/schema.ts
Normal file
7
src/routes/api/report/schema.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReportAddSchema = z.object({
|
||||
reporter: z.string(),
|
||||
reported: z.string().nullish(),
|
||||
reason: z.string()
|
||||
});
|
56
src/routes/api/user/+server.ts
Normal file
56
src/routes/api/user/+server.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Report, sequelize, StrikePunishment, StrikeReason, User } from '$lib/server/database';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export const GET = (async ({ url }) => {
|
||||
if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
|
||||
return new Response(null, { status: 401 });
|
||||
|
||||
const uuid = url.searchParams.get('uuid');
|
||||
if (uuid == null) return new Response(null, { status: 400 });
|
||||
|
||||
const user = await User.findOne({ where: { uuid: uuid } });
|
||||
if (user == null) return new Response(null, { status: 404 });
|
||||
|
||||
const query = (await Report.findOne({
|
||||
where: { reported_id: user.id },
|
||||
attributes: [
|
||||
[sequelize.fn('SUM', sequelize.col('strike_reason.weight')), 'weight'],
|
||||
[sequelize.fn('MAX', sequelize.col('striked_at')), 'striked_at']
|
||||
],
|
||||
include: { model: StrikeReason, as: 'strike_reason' }
|
||||
})) as { dataValues: { weight: number | null; striked_at: Date | null } } | null;
|
||||
const ban_time = (
|
||||
await StrikePunishment.findOne({
|
||||
attributes: ['punishment_in_seconds'],
|
||||
where: { type: ['ban'], weight: { [Op.lte]: query?.dataValues.weight } },
|
||||
order: [['weight', 'DESC']]
|
||||
})
|
||||
)?.punishment_in_seconds;
|
||||
const outlawed_time = (
|
||||
await StrikePunishment.findOne({
|
||||
attributes: ['punishment_in_seconds'],
|
||||
where: { type: ['outlawed'], weight: { [Op.lte]: query?.dataValues.weight } },
|
||||
order: [['weight', 'DESC']]
|
||||
})
|
||||
)?.punishment_in_seconds;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
banned_until:
|
||||
query && query.dataValues.striked_at && ban_time
|
||||
? Math.round(query.dataValues.striked_at.getTime() / 1000 + ban_time)
|
||||
: null,
|
||||
outlawed_until:
|
||||
query && query.dataValues.striked_at && outlawed_time
|
||||
? Math.round(query.dataValues.striked_at.getTime() / 1000 + outlawed_time)
|
||||
: null
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}) satisfies RequestHandler;
|
3
src/routes/faq/+layout.svelte
Normal file
3
src/routes/faq/+layout.svelte
Normal file
@ -0,0 +1,3 @@
|
||||
<div class="mx-4 my-6 sm:mx-24 sm:my-12">
|
||||
<slot />
|
||||
</div>
|
213
src/routes/faq/+page.svelte
Normal file
213
src/routes/faq/+page.svelte
Normal file
@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let faq = [
|
||||
{
|
||||
section: 'Allgemein',
|
||||
questions: [
|
||||
{
|
||||
title: 'Wie kann ich einen Admin kontaktieren?',
|
||||
content: `<p>Einen Admin kannst du im Chat, über WhatsApp, per Teamspeak (<a class="link" href="${env.PUBLIC_TS_LINK}">mhsl.eu</a>) oder Discord (<a class="link" href="https://discord.gg/EBGefWPc2K" target="_blank">https://discord.gg/EBGefWPc2K</a>) kontaktieren.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wer ist eigentlich Organisator und warum?',
|
||||
content: `<p>Wir sind ein kleines Team von Minecraft-Enthusiasten, das bereits im siebten Jahr in Folge
|
||||
Minecraft CraftAttack organisiert. Jedes Jahr arbeiten wir daran, das Spielerlebnis zu
|
||||
verbessern und die Teilnehmerzahl zu steigern. Weitere Infos findest du auf der Teamseite.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wie lange bleibt der Server online?',
|
||||
content: `<p>Der Server wird traditionell so lange online bleiben, wie noch aktiv darauf gespielt wird.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Warum benötigt ihr meine Daten bei der Anmeldung?',
|
||||
content: `<p>Deine Daten werden nur intern gespeichert und dienen den Admins rein zur Organisation
|
||||
des Projekts.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Gibt es einen Teamspeak-Server?',
|
||||
content: `<p>Ja, den offiziellen Teamspeak-Server erreichst du unter der IP <a class="link" href="${env.PUBLIC_TS_LINK}">mhsl.eu</a>.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Gibt es einen Discord-Server?',
|
||||
content: `<p>Ja, den offiziellen Discord-Server erreichst du unter <a class="link" href="${env.PUBLIC_DISCORD_LINK}" target="_blank">${env.PUBLIC_DISCORD_LINK}</a>.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wozu dient die CraftAttack-WhatsApp-Gruppe?',
|
||||
content: `<p>In der WhatsApp-Gruppe erhältst du alle wichtigen Infos bezüglich CraftAttack.</p>`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Anmeldung',
|
||||
questions: [
|
||||
{
|
||||
title: 'Wann startet CraftAttack 7?',
|
||||
content: `<p>Der Start von CraftAttack 7 findet gemeinsam am 27.12.2024 um 14:00 Uhr statt. Am besten
|
||||
bist du schon einige Minuten vorher auf dem Server. Natürlich kannst du aber auch danach
|
||||
jederzeit dazustoßen.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wer kann alles mitspielen?',
|
||||
content: `<p>Jeder, der entweder Minecraft Java oder Bedrock (Handy und Konsole) besitzt und
|
||||
mindestens 6 Jahre alt ist, kann mitspielen.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wie kann ich mitspielen?',
|
||||
content: `<p>Um mitzuspielen, musst du dich einfach hier auf der Website anmelden und der WhatsApp-
|
||||
Gruppe beitreten.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Auf welcher Version läuft der Server?',
|
||||
content: `<p>Gespielt wird immer auf der neuesten Version, also laut aktuellem Stand Version 1.21.4.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Kann ich auch als Bedrock-Spieler (Handy oder Konsole) mitspielen?',
|
||||
content: `<p>Ja, auch als Bedrock-Spieler kannst du mitspielen, sofern du anderen Servern beitreten kannst.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Ich kann mich nicht anmelden, was kann ich tun?',
|
||||
content: `<p>Wenn du dich nicht anmelden kannst, solltest du Folgendes überprüfen:</p>
|
||||
<ol class="list-decimal pl-8 py-3">
|
||||
<li>Ist dein Spielername korrekt geschrieben?</li>
|
||||
<li>Hast du dich bereits angemeldet? Es ist nur ein Account pro Spieler erlaubt.</li>
|
||||
<li>Hast du die richtige Spieledition ausgewählt?</li>
|
||||
</ol>
|
||||
<p>Falls du dich aus unerklärlichen Gründen trotzdem nicht anmelden kannst, kannst du
|
||||
dich jederzeit beim Admin-Team melden.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Ich komme nicht auf den Server, was kann ich tun?',
|
||||
content: `<p>Wenn du dem Server nicht beitreten kannst, überprüfe Folgendes:</p>
|
||||
<ol class="list-decimal pl-8 py-3">
|
||||
<li>Hast du die korrekte IP verwendet? Sie lautet <span class="underline italic">${env.PUBLIC_SERVER_IP}</span>.</li>
|
||||
<li>Hast du Leerzeichen verwendet, insbesondere vor oder hinter der IP, oder dich vertippt?</li>
|
||||
<li>Kommst du auf andere Server, oder ist es nur ein Problem beim CraftAttack-Server?</li>
|
||||
<li>Hast du dich korrekt auf der Webseite angemeldet?</li>
|
||||
</ol>
|
||||
<p>Falls du trotzdem nicht beitreten kannst, melde dich beim Admin-Team und halte die
|
||||
Fehlermeldung bereit.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was ist die Server-IP?',
|
||||
content: `<p>Die Serveradresse lautet: <span class="underline italic">${env.PUBLIC_SERVER_IP}</span>.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Ist es kostenlos mitzuspielen?',
|
||||
content: `<p>Ja, die Teilnahme ist selbstverständlich kostenlos.${
|
||||
env.PUBLIC_PAYPAL_LINK
|
||||
? `Wir freuen uns aber, wenn du das Projekt mit einer Spende nach der Anmeldung unterstützen würdest.<br>
|
||||
Hier kannst du für das Projekt spenden: <a class="link" href=${env.PUBLIC_PAYPAL_LINK} target="_blank">${env.PUBLIC_PAYPAL_LINK}</a>.`
|
||||
: ''
|
||||
}</p>`
|
||||
},
|
||||
{
|
||||
title:
|
||||
'Die Anmeldefrist ist vorbei, aber ich möchte mich trotzdem noch anmelden. Was kann ich tun?',
|
||||
content: `<p>Generell solltest du dich immer während des Anmeldezeitraums anmelden. Falls die
|
||||
Anmeldung allerdings bereits geschlossen ist, kannst du einen Admin kontaktieren, der dich
|
||||
im Fall der Fälle noch nachträglich anmelden kann.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Ist ein 2. Account erlaubt?',
|
||||
content: `<p>Nein, pro Teilnehmer ist nur ein Account zugelassen.</p>`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Ingame',
|
||||
questions: [
|
||||
{
|
||||
title: 'Wo kann ich meinen Shop errichten?',
|
||||
content: `<p>Generell darfst du Shops überall errichten, aber es bietet sich an, alle Shops in einem
|
||||
Shopping-District nahe des Spawns anzusiedeln.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Sind Farmen erlaubt?',
|
||||
content: `<p>Ja, Farmen sind generell erlaubt. Allerdings sind lag-erzeugende Maschinen, Farmen (Zero-
|
||||
Tick-Farmen etc.) oder andere Bauten, die den Spielfluss stören könnten, verboten.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was und wann sind Events?',
|
||||
content: `<p>Abends, meist gegen 18 Uhr, finden gelegentlich Events statt, bei denen du Items gewinnen
|
||||
kannst und in kleinen Minispielen gegen deine Mitspieler antrittst. Die genauen Abläufe
|
||||
siehst du, wenn du abends auf dem Server bist.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wo und wie kann ich einen Regelverstoß melden?',
|
||||
content: `<p>Wenn du einen Regelverstoß melden willst, kannst du ingame den Befehl <code>/report</code> nutzen, um
|
||||
einen Admin zu kontaktieren.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was hat es mit dem Blutmond auf sich?',
|
||||
content: `<p>Alle dreißig ingame-Tage solltest du nachts auf der Hut sein, denn die Monster sind in dieser
|
||||
Nacht deutlich stärker als üblich, droppen aber auch besseren Loot.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was hat es mit dem Vogelfrei-Modus auf sich?',
|
||||
content: `<p>CraftAttack ist grundsätzlich ein friedliches Projekt. Falls du jedoch kein Problem damit hast,
|
||||
angegriffen zu werden, kannst du dich mit <code>/vogelfrei</code> in den Vogelfrei-Modus setzen.
|
||||
Dadurch sehen andere Spieler, dass du für einen Kampf offen bist. Der Vogelfrei-Modus kann
|
||||
allerdings erst nach einigen Stunden wieder beendet werden.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was hat es mit dem Rang „Langzeitspieler“ auf sich?',
|
||||
content: `<p>Spieler, die seit über drei Jahren am Projekt teilnehmen, erhalten den Langzeitrang. Dieser
|
||||
wirkt sich allerdings nicht auf das Spielgeschehen aus.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Was gibt es für neue Features?',
|
||||
content: `<ul class="list-disc pl-8">
|
||||
<li>Miniböcke, die du selbst gestalten kannst</li>
|
||||
<li>Neue Event-Spiele</li>
|
||||
<li>Einige Quality-of-Life-Features, die du mit <code>/settings</code> erreichst</li>
|
||||
<li>Langzeitrang</li>
|
||||
</ul>`
|
||||
},
|
||||
{
|
||||
title: 'Wann wird das End geöffnet?',
|
||||
content: `<p>Das End wird gemeinsam am 03.01.2025 um 19:00 Uhr geöffnet, und wir besiegen
|
||||
gemeinsam den Enderdrachen.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Darf ich andere Spieler töten?',
|
||||
content: `<p>Andere Spieler zu töten ist generell verboten. Wenn es jedoch nur zum Spaß und mit dem
|
||||
anderen Spieler abgesprochen ist, haben wir nichts dagegen einzuwenden. Außerdem ist es
|
||||
erlaubt, vogelfreie Spieler zu töten.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Welche Minecraft-Clients sind erlaubt?',
|
||||
content: `<p>Jegliche Clientmodifikationen, die deutliche Vorteile gegenüber anderen Spielern bringen,
|
||||
sind nicht gestattet.</p>`
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Craftattack - FAQ</title>
|
||||
<meta property="og:title" content="Craftattack - FAQ" />
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-3xl lg:text-5xl mb-16 text-center">FAQ</h1>
|
||||
<div class="grid lg:grid-cols-2 2xl:grid-cols-3 gap-10">
|
||||
{#each faq as questions}
|
||||
<div>
|
||||
<h2 class="text-4xl text-center mb-3">{questions.section}</h2>
|
||||
<div>
|
||||
{#each questions.questions as question}
|
||||
<div class="collapse collapse-arrow">
|
||||
<input type="checkbox" autocomplete="off" />
|
||||
<div class="collapse-title">{question.title}</div>
|
||||
<div class="collapse-content">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="ml-2">{@html question.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
5
src/routes/feedback/+layout.svelte
Normal file
5
src/routes/feedback/+layout.svelte
Normal file
@ -0,0 +1,5 @@
|
||||
<div class="flex justify-center items-center w-full">
|
||||
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
82
src/routes/feedback/+page.svelte
Normal file
82
src/routes/feedback/+page.svelte
Normal file
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Textarea from '$lib/components/Input/Textarea.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let content = $state('');
|
||||
let type = $state('feedback');
|
||||
|
||||
async function submitFeedback() {
|
||||
await fetch(`${env.PUBLIC_BASE_PATH}/feedback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: content,
|
||||
type: type
|
||||
})
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback & Kontakt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h2 class="text-3xl text-center">Feedback & Kontakt</h2>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
showPopupModal({
|
||||
title: `${type === 'feedback' ? 'Feedback' : 'Kontaktanfrage'} abschicken?`,
|
||||
text:
|
||||
type === 'feedback'
|
||||
? 'Nach dem Abschicken des Feedbacks lässt es sich nicht mehr bearbeiten.'
|
||||
: 'Bitte hinterlege eine Rückmeldemöglichkeit in deiner Anfrage. Nachdem sie abgeschickt wurde, kannst du die Nachricht nicht mehr bearbeiten.',
|
||||
actions: [
|
||||
{
|
||||
text: 'Abschicken',
|
||||
action: () =>
|
||||
setTimeout(async () => {
|
||||
await submitFeedback();
|
||||
|
||||
showPopupModal({
|
||||
title: `${type === 'feedback' ? 'Feedback' : 'Kontaktanfrage'} abgeschickt`,
|
||||
text:
|
||||
type === 'feedback'
|
||||
? 'Dein Feedback wurde abgeschickt.'
|
||||
: 'Deine Kontaktanfrage wurde abgeschickt. Jemand aus dem Team wird sich nächstmöglich bei Dir melden.',
|
||||
actions: [{ text: 'Schließen' }],
|
||||
onClose: () => {
|
||||
content = '';
|
||||
type = 'feedback';
|
||||
}
|
||||
});
|
||||
}, 200)
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4 mt-6 mb-4">
|
||||
<Select size="sm" bind:value={type}>
|
||||
<option value="feedback">Feedback</option>
|
||||
<option value="contact">Kontakt</option>
|
||||
</Select>
|
||||
<Textarea
|
||||
required={true}
|
||||
rows={4}
|
||||
label={type === 'feedback' ? 'Feedback' : 'Anfrage'}
|
||||
bind:value={content}
|
||||
/>
|
||||
<div>
|
||||
<Input type="submit" disabled={type === '' || content === ''} value="Senden" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
20
src/routes/feedback/+server.ts
Normal file
20
src/routes/feedback/+server.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Feedback } from '$lib/server/database';
|
||||
import { FeedbackSubmitSchema } from './schema';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const POST = (async ({ request }) => {
|
||||
const parseResult = await FeedbackSubmitSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
await Feedback.create({
|
||||
event: `website-${data.type}`,
|
||||
content: data.content,
|
||||
url_hash: crypto.randomBytes(18).toString('hex')
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}) satisfies RequestHandler;
|
18
src/routes/feedback/[...url_hash]/+page.server.ts
Normal file
18
src/routes/feedback/[...url_hash]/+page.server.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { Feedback } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const feedback = await Feedback.findOne({
|
||||
where: { url_hash: params.url_hash }
|
||||
});
|
||||
|
||||
if (!feedback) throw redirect(302, `${env.PUBLIC_BASE_PATH}/`);
|
||||
|
||||
return {
|
||||
draft: feedback.content === null,
|
||||
title: feedback.title,
|
||||
anonymous: feedback.user_id === null
|
||||
};
|
||||
};
|
25
src/routes/feedback/[...url_hash]/+page.svelte
Normal file
25
src/routes/feedback/[...url_hash]/+page.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import FeedbackDraft from './FeedbackDraft.svelte';
|
||||
import FeedbackSent from './FeedbackSent.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let draft = $state(data.draft);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback</title>
|
||||
<!-- just in case... -->
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
{#if draft}
|
||||
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
|
||||
<FeedbackDraft title={data.title} anonymous={data.anonymous} onSubmit={() => (draft = false)} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}>
|
||||
<FeedbackSent />
|
||||
</div>
|
||||
{/if}
|
24
src/routes/feedback/[...url_hash]/+server.ts
Normal file
24
src/routes/feedback/[...url_hash]/+server.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { Feedback } from '$lib/server/database';
|
||||
import { FeedbackSubmitSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request, params }) => {
|
||||
const feedback = await Feedback.findOne({ where: { url_hash: params.url_hash } });
|
||||
|
||||
if (feedback == null) return new Response(null, { status: 400 });
|
||||
|
||||
const parseResult = await FeedbackSubmitSchema.safeParseAsync(await request.json());
|
||||
if (!parseResult.success) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
|
||||
feedback.content = data.content;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (data.anonymous) feedback.user_id = null;
|
||||
|
||||
await feedback.save();
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}) satisfies RequestHandler;
|
76
src/routes/feedback/[...url_hash]/FeedbackDraft.svelte
Normal file
76
src/routes/feedback/[...url_hash]/FeedbackDraft.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Textarea from '$lib/components/Input/Textarea.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { page } from '$app/stores';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
let {
|
||||
title,
|
||||
anonymous,
|
||||
onSubmit
|
||||
}: { title: string | null; anonymous: boolean; onSubmit?: () => void } = $props();
|
||||
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
let content = $state('');
|
||||
let sendAnonymous = $state(false);
|
||||
|
||||
async function submitFeedback() {
|
||||
await fetch(`${env.PUBLIC_BASE_PATH}/feedback/${$page.params.url_hash}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: content,
|
||||
anonymous: sendAnonymous
|
||||
})
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2 class="text-3xl text-center">Feedback</h2>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
showPopupModal({
|
||||
title: 'Feedback abschicken?',
|
||||
text: 'Nach dem Abschicken des Feedbacks lässt es sich nicht mehr bearbeiten.',
|
||||
actions: [
|
||||
{
|
||||
text: 'Abschicken',
|
||||
action: async () => {
|
||||
await submitFeedback();
|
||||
onSubmit?.();
|
||||
}
|
||||
},
|
||||
{ text: 'Abbrechen' }
|
||||
]
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4 my-4">
|
||||
{#if title}
|
||||
<Input size="sm" pickyWidth={false} disabled value={title}>
|
||||
{#snippet label()}
|
||||
<span>Event</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
{/if}
|
||||
<Textarea required={true} rows={4} label="Feedback" bind:value={content} />
|
||||
{#if !anonymous}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Input type="checkbox" id="anonymous" size="xs" bind:checked={sendAnonymous} />
|
||||
<label
|
||||
for="anonymous"
|
||||
title="Dein Spielername wird nach dem Abschicken nicht mit dem Feedback zusammen gespeichert"
|
||||
>Anonym senden</label
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Input type="submit" disabled={content === ''} value="Feedback senden" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
4
src/routes/feedback/[...url_hash]/FeedbackSent.svelte
Normal file
4
src/routes/feedback/[...url_hash]/FeedbackSent.svelte
Normal file
@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h2 class="text-2xl text-center">Feedback abgeschickt</h2>
|
||||
<p class="mt-4">Das Feedback wurde abgeschickt.</p>
|
||||
</div>
|
6
src/routes/feedback/[...url_hash]/schema.ts
Normal file
6
src/routes/feedback/[...url_hash]/schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FeedbackSubmitSchema = z.object({
|
||||
content: z.string(),
|
||||
anonymous: z.boolean()
|
||||
});
|
6
src/routes/feedback/schema.ts
Normal file
6
src/routes/feedback/schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FeedbackSubmitSchema = z.object({
|
||||
content: z.string(),
|
||||
type: z.enum(['feedback', 'contact'])
|
||||
});
|
@ -1,3 +1,3 @@
|
||||
<div class="flex justify-center w-full">
|
||||
<div class="flex justify-center w-full min-h-screen bg-base-200">
|
||||
<slot />
|
||||
</div>
|
||||
|
13
src/routes/register/+page.server.ts
Normal file
13
src/routes/register/+page.server.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { Settings } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
enabled: (await Settings.findOne({ where: { key: 'register.enabled' } }))?.value ?? true,
|
||||
disabled_title:
|
||||
(await Settings.findOne({ where: { key: 'register.disabled_title' } }))?.value ??
|
||||
'Anmeldung geschlossen',
|
||||
disabled_details:
|
||||
(await Settings.findOne({ where: { key: 'register.disabled_details' } }))?.value ?? ''
|
||||
};
|
||||
};
|
@ -3,25 +3,61 @@
|
||||
import RegistrationComplete from './RegistrationComplete.svelte';
|
||||
import Register from './Register.svelte';
|
||||
|
||||
let registered = false;
|
||||
let { data } = $props();
|
||||
|
||||
let registered = $state(false);
|
||||
let firstname: string | null = $state(null);
|
||||
let lastname: string | null = $state(null);
|
||||
let birthday: Date | null = $state(null);
|
||||
let phone: string | null = $state(null);
|
||||
let username: string | null = $state(null);
|
||||
let edition: string | null = $state(null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Craftattack - Anmeldung</title>
|
||||
<meta property="og:title" content="Craftattack - Anmeldung" />
|
||||
</svelte:head>
|
||||
|
||||
<!--the tooltip when not all fields are correctly filled won't completely show if the overflow is hidden-->
|
||||
<div
|
||||
class="absolute top-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg"
|
||||
class="relative grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 my-12 bg-base-100 shadow-lg h-min"
|
||||
class:overflow-hidden={registered}
|
||||
>
|
||||
{#if !data.enabled}
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 backdrop-blur-sm z-10 rounded-xl flex lg:justify-center items-center flex-col pt-20 lg:pt-0"
|
||||
>
|
||||
<h1 class="text-2xl sm:text-3xl md:text-5xl text-white">{data.disabled_title}</h1>
|
||||
<h3>{data.disabled_details}</h3>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !registered}
|
||||
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
|
||||
<Register on:submit={() => (registered = true)} />
|
||||
<Register
|
||||
submit={(e) => {
|
||||
registered = true;
|
||||
firstname = e.firstname;
|
||||
lastname = e.lastname;
|
||||
birthday = e.birthday;
|
||||
phone = e.phone;
|
||||
phone = e.phone;
|
||||
username = e.username;
|
||||
edition = e.edition;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}>
|
||||
<RegistrationComplete on:close={() => (registered = false)} />
|
||||
<RegistrationComplete
|
||||
{firstname}
|
||||
{lastname}
|
||||
{birthday}
|
||||
{phone}
|
||||
{username}
|
||||
{edition}
|
||||
close={() => (registered = false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -1,47 +1,100 @@
|
||||
import {
|
||||
getBedrockUuid,
|
||||
getCrackedUuid,
|
||||
ApiError,
|
||||
getJavaUuid,
|
||||
getNoAuthUuid,
|
||||
RateLimitError,
|
||||
UserNotFoundError
|
||||
} from '$lib/server/minecraft';
|
||||
import { error, type RequestHandler } from '@sveltejs/kit';
|
||||
import { User } from '$lib/server/database';
|
||||
import { Settings, User } from '$lib/server/database';
|
||||
import { RegisterSchema } from './schema';
|
||||
|
||||
export const POST = (async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
if ((await Settings.findOne({ where: { key: 'register.enabled' } }))?.value === false) {
|
||||
throw error(400, 'Anmeldung geschlossen');
|
||||
}
|
||||
|
||||
let uuid: string;
|
||||
try {
|
||||
// available playertypes are 'java', 'bedrock' and 'cracked'
|
||||
switch (data.get('playertype')) {
|
||||
// eslint-disable-next-line no-var
|
||||
var data = RegisterSchema.parse(await request.json());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw error(400, 'Ungültige Parameter');
|
||||
}
|
||||
|
||||
let uuid: string | null;
|
||||
try {
|
||||
switch (data.playertype) {
|
||||
case 'java':
|
||||
uuid = await getJavaUuid(data.get('username') as string);
|
||||
uuid = await getJavaUuid(data.username);
|
||||
break;
|
||||
case 'bedrock':
|
||||
uuid = await getBedrockUuid(data.get('username') as string);
|
||||
uuid = null;
|
||||
// uuid = await getBedrockUuid(username);
|
||||
break;
|
||||
case 'cracked':
|
||||
uuid = getCrackedUuid(data.get('username') as string);
|
||||
case 'noauth':
|
||||
uuid = getNoAuthUuid(data.username);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`invalid player type (${data.get('playertype')})`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof UserNotFoundError) {
|
||||
throw error(400, e.message);
|
||||
}
|
||||
throw error(
|
||||
400,
|
||||
"Der Spielername '" +
|
||||
data.username +
|
||||
"' existiert nicht. Hast Du Deinen Spielernamen korrekt geschrieben " +
|
||||
'und besitzt Du einen Minecraft-Account?\n\nKontaktiere bitte einen Admin, falls Du Dich trotz korrekter ' +
|
||||
'Angabe nicht registrieren kannst.'
|
||||
);
|
||||
} else if (e instanceof ApiError) {
|
||||
console.error((e as Error).message);
|
||||
return new Response();
|
||||
uuid = null;
|
||||
} else if (e instanceof RateLimitError) {
|
||||
console.error(`uuid request rate limited for user '${data.username}'`);
|
||||
uuid = null;
|
||||
} else {
|
||||
console.error((e as Error).message);
|
||||
throw error(500);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(uuid && (await User.findOne({ where: { uuid: uuid } }))) ||
|
||||
(!uuid && (await User.findOne({ where: { username: data.username } })))
|
||||
) {
|
||||
throw error(
|
||||
400,
|
||||
'Dein Minecraft-Account wurde bereits registriert.\n\nKontaktiere bitte einen Admin, falls diese ' +
|
||||
'Informationen für Dich fehlerhaft erscheinen oder Du Angaben Deiner bestehenden Registrierung verändern ' +
|
||||
'möchtest.'
|
||||
);
|
||||
} else if (
|
||||
await User.findOne({
|
||||
where: {
|
||||
firstname: data.firstname,
|
||||
lastname: data.lastname,
|
||||
birthday: data.birthday.toUTCString()
|
||||
}
|
||||
})
|
||||
) {
|
||||
throw error(
|
||||
400,
|
||||
'In Deinem Namen wurde bereits ein Minecraft-Account registriert. Es ist nur ein Account pro Spieler ' +
|
||||
'erlaubt.\n\nKontaktiere bitte einen Admin, falls diese Informationen für Dich fehlerhaft erscheinen oder ' +
|
||||
'Du Angaben Deiner bestehenden Registrierung verändern möchtest.'
|
||||
);
|
||||
}
|
||||
|
||||
await User.create({
|
||||
firstname: data.get('firstname'),
|
||||
lastname: data.get('lastname'),
|
||||
birthday: data.get('birthday'),
|
||||
telephone: data.get('telephone'),
|
||||
username: data.get('username'),
|
||||
playertype: data.get('playertype'),
|
||||
password: data.get('password'),
|
||||
firstname: data.firstname,
|
||||
lastname: data.lastname,
|
||||
birthday: data.birthday.toUTCString(),
|
||||
telephone: data.telephone,
|
||||
username: data.username,
|
||||
playertype: data.playertype,
|
||||
password: null,
|
||||
uuid: uuid
|
||||
});
|
||||
|
||||
|
@ -1,22 +1,57 @@
|
||||
<script lang="ts">
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { rules } from '$lib/rules';
|
||||
import { rulesShort } from '$lib/rules';
|
||||
import { RegisterSchema } from './schema';
|
||||
import { dev } from '$app/environment';
|
||||
import { getPopupModalShowFn } from '$lib/context';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
submit
|
||||
}: {
|
||||
submit: ({
|
||||
firstname,
|
||||
lastname,
|
||||
birthday,
|
||||
phone,
|
||||
username,
|
||||
edition
|
||||
}: {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
birthday: Date;
|
||||
phone: string;
|
||||
username: string;
|
||||
edition: string;
|
||||
}) => void;
|
||||
} = $props();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
let checkInputs = () => {};
|
||||
let playertype = 'java';
|
||||
let firstnameInput: HTMLInputElement;
|
||||
let lastnameInput: HTMLInputElement;
|
||||
let birthdayInput: HTMLInputElement;
|
||||
let usernameInput: HTMLInputElement;
|
||||
let privacyInput: HTMLInputElement;
|
||||
let logsInput: HTMLInputElement;
|
||||
let rulesInput: HTMLInputElement;
|
||||
let showPopupModal = getPopupModalShowFn();
|
||||
|
||||
const modalTimeoutSeconds = dev ? 0 : 30;
|
||||
|
||||
let checkInputs = $state(() => {});
|
||||
let playertype = $state('java');
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-ignore
|
||||
let firstnameInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let lastnameInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let birthdayInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let phoneInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let usernameInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let privacyInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let logsInput: HTMLInputElement = $state();
|
||||
// @ts-ignore
|
||||
let rulesInput: HTMLInputElement = $state();
|
||||
/* eslint-enable @typescript-eslint/ban-ts-comment */
|
||||
onMount(() => {
|
||||
checkInputs = () => {
|
||||
let allInputs = [
|
||||
@ -38,31 +73,69 @@
|
||||
|
||||
async function sendRegister() {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
registerRequest = new Promise(async (resolve, reject) => {
|
||||
registerRequest = new Promise<void>(async (resolve, reject) => {
|
||||
const parseResult = RegisterSchema.safeParse(
|
||||
Object.fromEntries(new FormData(document.forms[0]))
|
||||
);
|
||||
|
||||
if (!parseResult.success) {
|
||||
reject(Error(parseResult.error.issues.map((i) => i.message).join('\n')));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${env.PUBLIC_BASE_PATH}/register`, {
|
||||
method: 'POST',
|
||||
body: new FormData(document.forms[0])
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
|
||||
});
|
||||
if (response.ok) {
|
||||
dispatch('submit', {});
|
||||
submit({
|
||||
firstname: firstnameInput.value,
|
||||
lastname: lastnameInput.value,
|
||||
birthday: birthdayInput.valueAsDate!,
|
||||
phone: phoneInput.value,
|
||||
username: usernameInput.value,
|
||||
edition: playertype == 'java' ? 'Java (PC)' : 'Bedrock (Konsolen und Handys)'
|
||||
});
|
||||
resolve();
|
||||
} else if (response.status < 500) {
|
||||
reject(Error((await response.json()).message));
|
||||
} else {
|
||||
reject(Error(`${response.statusText} (${response.status})`));
|
||||
}
|
||||
}).catch((e) => {
|
||||
errorMessage = (e as Error).message;
|
||||
registerRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
let rulesAccepted = false;
|
||||
let rulesModal: HTMLDialogElement | null = null;
|
||||
let rulesModal: HTMLDialogElement;
|
||||
let rulesModalSecondsOpened = $state(0);
|
||||
// eslint-disable-next-line no-undef
|
||||
let rulesModalTimer: number | NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
let inputsInvalidMessage: string | null = 'Bitte fülle alle erforderlichen Felder aus';
|
||||
let registerRequest: Promise<void> | null = null;
|
||||
let inputsInvalidMessage: string | null = $state('Bitte fülle alle erforderlichen Felder aus');
|
||||
let registerRequest: Promise<void> | null = $state(null);
|
||||
let errorMessage: string = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!errorMessage) return;
|
||||
showPopupModal({
|
||||
title: 'Fehler',
|
||||
text: errorMessage
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1 class="text-center text-3xl lg:text-5xl">Anmeldung</h1>
|
||||
<form id="form" on:input={checkInputs} on:submit|preventDefault={sendRegister}>
|
||||
<form
|
||||
id="form"
|
||||
oninput={checkInputs}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendRegister();
|
||||
}}
|
||||
>
|
||||
<div class="divider">Persönliche Angaben</div>
|
||||
<div class="mx-2 grid grid-cols-1 sm:grid-cols-2 gap-y-4">
|
||||
<Input
|
||||
@ -72,7 +145,9 @@
|
||||
required={true}
|
||||
bind:inputElement={firstnameInput}
|
||||
>
|
||||
<span slot="label">Vorname</span>
|
||||
{#snippet label()}
|
||||
<span>Vorname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input
|
||||
id="lastname"
|
||||
@ -81,7 +156,9 @@
|
||||
required={true}
|
||||
bind:inputElement={lastnameInput}
|
||||
>
|
||||
<span slot="label">Nachname</span>
|
||||
{#snippet label()}
|
||||
<span>Nachname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input
|
||||
id="birthday"
|
||||
@ -90,16 +167,30 @@
|
||||
required={true}
|
||||
bind:inputElement={birthdayInput}
|
||||
>
|
||||
<span slot="label">Geburtstag</span>
|
||||
<span slot="notice">Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span>
|
||||
{#snippet label()}
|
||||
<span>Geburtstag</span>
|
||||
{/snippet}
|
||||
{#snippet notice()}
|
||||
<span>Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input id="telephone" name="telephone" type="tel">
|
||||
<span slot="label">Telefonnummer</span>
|
||||
<p slot="notice">
|
||||
<Input
|
||||
id="telephone"
|
||||
name="telephone"
|
||||
type="tel"
|
||||
bind:inputElement={phoneInput}
|
||||
pattern={new RegExp(/^[+()\s/\d]+$/)}
|
||||
>
|
||||
{#snippet label()}
|
||||
<span>Telefonnummer</span>
|
||||
{/snippet}
|
||||
{#snippet notice()}
|
||||
<p>
|
||||
Diese nutzen wir, um Dich in der Whatsapp-Gruppe zuzuordnen und kontaktieren zu können.
|
||||
<br />
|
||||
<b>Die Angabe ist freiwillig, hilft den Administratoren jedoch sehr!</b>
|
||||
</p>
|
||||
{/snippet}
|
||||
</Input>
|
||||
</div>
|
||||
<div class="divider">Spiel</div>
|
||||
@ -111,7 +202,9 @@
|
||||
required={true}
|
||||
bind:inputElement={usernameInput}
|
||||
>
|
||||
<span slot="label">Minecraft-Spielername</span>
|
||||
{#snippet label()}
|
||||
<span>Minecraft-Spielername</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Select
|
||||
id="playertype"
|
||||
@ -120,26 +213,11 @@
|
||||
bind:value={playertype}
|
||||
required={true}
|
||||
>
|
||||
<option value="java">Java Edition</option>
|
||||
<option value="bedrock">Bedrock Edition</option>
|
||||
<option value="cracked">Java cracked</option>
|
||||
<option value="java">Java Edition (PC)</option>
|
||||
<option value="bedrock">Bedrock Edition (Konsolen und Handys)</option>
|
||||
</Select>
|
||||
{#if playertype === 'cracked'}
|
||||
<div class="sm:col-span-2">
|
||||
<Input id="password" name="password" type="password" required={true}>
|
||||
<span slot="label">Passwort</span>
|
||||
<span slot="notice">
|
||||
Da Du cracked spielst, musst Du ein Passwort festlegen, mit welchem Du Dich auf dem
|
||||
Server authentifizierst! Das Passwort wird im Klartext gespeichert und ist in deinen
|
||||
Clientlogs sowie in Serverlogs für Admins sichtbar. Verwende daher ein neues Passwort,
|
||||
welches Du nirgends sonst verwendest! Merke Dir das Passwort gut, ohne kannst Du Dich
|
||||
nicht auf dem Server einloggen
|
||||
</span>
|
||||
</Input>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider" />
|
||||
<div class="divider"></div>
|
||||
<div class="mx-2 grid gap-y-3 mb-6">
|
||||
<div class="flex gap-4">
|
||||
<Input
|
||||
@ -152,7 +230,9 @@
|
||||
<label for="privacy">
|
||||
<span>
|
||||
Ich bin mit der Speicherung meiner in der Anmeldung angegebenen, persönlichen Daten
|
||||
einverstanden. Siehe <a class="link" href="https://mhsl.eu/id.html">Datenschutz</a>
|
||||
einverstanden. Siehe <a class="link" href="https://mhsl.eu/id.html" target="_blank"
|
||||
>Datenschutz</a
|
||||
>
|
||||
</span>
|
||||
<span class="text-red-700">*</span>
|
||||
</label>
|
||||
@ -165,6 +245,11 @@
|
||||
persönlichen Daten durch den Server einverstanden
|
||||
</span>
|
||||
<span class="text-red-700">*</span>
|
||||
<br />
|
||||
<p class="text-[.75rem]">
|
||||
Dies betrifft jede Interaktion im Spiel und zugehörige Daten wie z.B. Chatnachrichten
|
||||
welche vom Minecraft Client an den Server übermittelt werden
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
@ -173,25 +258,26 @@
|
||||
name="rules"
|
||||
type="checkbox"
|
||||
required={true}
|
||||
on:input={(e) => {
|
||||
oninput={(e) => {
|
||||
if (!rulesAccepted) {
|
||||
e.detail.target.checked = false;
|
||||
e.currentTarget.checked = false;
|
||||
rulesModal.show();
|
||||
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
|
||||
}
|
||||
}}
|
||||
bind:inputElement={rulesInput}
|
||||
/>
|
||||
<label for="rules">
|
||||
Ich bin mit den <a target="_blank" class="link" href="{env.PUBLIC_BASE_PATH}/rules"
|
||||
>Regeln</a
|
||||
Ich bin mit den <button
|
||||
class="link"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
rulesModal.show();
|
||||
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
|
||||
}}>Regeln</button
|
||||
>
|
||||
einverstanden und achte sie
|
||||
<span class="text-red-700">*</span>
|
||||
<br />
|
||||
<p class="text-[.75rem]">
|
||||
Dies betrifft jede Interaktion im Spiel und zugehörige Daten wie z.B. Chatnachrichten
|
||||
welche vom Minecraft Client an den Server übermittelt werden
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,32 +298,24 @@
|
||||
{#await registerRequest}
|
||||
<span
|
||||
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
|
||||
/>
|
||||
{:catch error}
|
||||
<dialog
|
||||
class="modal"
|
||||
on:close={() => setTimeout(() => (registerRequest = null), 200)}
|
||||
open
|
||||
>
|
||||
<form method="dialog" class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<h3 class="font-bold text-lg">Error</h3>
|
||||
<p class="py-4">{error.message}</p>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
></span>
|
||||
{/await}
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<dialog class="modal" bind:this={rulesModal}>
|
||||
<dialog
|
||||
class="modal"
|
||||
onclose={() => {
|
||||
clearInterval(rulesModalTimer);
|
||||
rulesModalTimer = undefined;
|
||||
}}
|
||||
bind:this={rulesModal}
|
||||
>
|
||||
<form method="dialog" class="modal-box flex 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">
|
||||
<div class="overflow-auto mt-5">
|
||||
<div class="mb-4">
|
||||
<div class="collapse collapse-arrow">
|
||||
<input type="checkbox" autocomplete="off" checked />
|
||||
@ -245,12 +323,12 @@
|
||||
<p>0. Vorwort</p>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<p>{rules.header}</p>
|
||||
<p class="mt-1 text-[.75rem]">{rules.footer}</p>
|
||||
<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 class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||
</div>
|
||||
{#each rules.sections as section, i}
|
||||
{#each rulesShort.sections as section, i}
|
||||
<div class="collapse collapse-arrow">
|
||||
<input type="checkbox" autocomplete="off" />
|
||||
<div class="collapse-title">
|
||||
@ -260,17 +338,41 @@
|
||||
<p>{section.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600" />
|
||||
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="relative w-min"
|
||||
title={rulesModalSecondsOpened < modalTimeoutSeconds
|
||||
? `Regeln können in ${Math.max(
|
||||
modalTimeoutSeconds - rulesModalSecondsOpened,
|
||||
0
|
||||
)} Sekunden akzeptiert werden`
|
||||
: ''}
|
||||
onclick={() => {
|
||||
if (rulesModalSecondsOpened < modalTimeoutSeconds) {
|
||||
errorMessage =
|
||||
'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
|
||||
<div
|
||||
style="width: {Math.min((rulesModalSecondsOpened / modalTimeoutSeconds) * 100, 100)}%"
|
||||
class="h-full bg-base-300"
|
||||
></div>
|
||||
</div>
|
||||
<Input
|
||||
id="rules-accept"
|
||||
type="submit"
|
||||
value="Akzeptieren"
|
||||
on:click={() => {
|
||||
disabled={rulesModalSecondsOpened < modalTimeoutSeconds}
|
||||
containerClass="bg-transparent z-[1] relative"
|
||||
onclick={() => {
|
||||
rulesAccepted = true;
|
||||
rulesInput.checked = true;
|
||||
checkInputs();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,9 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { IconSolid } from 'svelte-heros-v2';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Input from '$lib/components/Input/Input.svelte';
|
||||
import Select from '$lib/components/Input/Select.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
firstname,
|
||||
lastname,
|
||||
birthday,
|
||||
phone,
|
||||
username,
|
||||
edition,
|
||||
close
|
||||
}: {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
birthday: Date;
|
||||
phone?: string;
|
||||
username: string;
|
||||
edition: string;
|
||||
close: () => void;
|
||||
} = $props();
|
||||
|
||||
let startDayOptions: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
@ -14,19 +31,95 @@
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
};
|
||||
let skin: string | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
let skinview3d = await import('skinview3d');
|
||||
let 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/${username}`);
|
||||
skinViewer.render();
|
||||
skin = skinViewer.canvas.toDataURL();
|
||||
|
||||
skinViewer.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center h-12 mb-2">
|
||||
<button class="sm:absolute btn btn-sm btn-square" on:click={() => dispatch('close')}>
|
||||
<IconSolid name="chevron-left-solid" />
|
||||
</button>
|
||||
<h1 class="text-center text-xl sm:text-3xl m-auto">Registrierung erfolgreich</h1>
|
||||
</div>
|
||||
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
|
||||
<p>
|
||||
<b>Du hast dich erfolgreich für Craftattack 6 registriert</b>. Spielstart ist am {new Date(
|
||||
env.PUBLIC_START_DATE
|
||||
).toLocaleString('de-DE', startDayOptions)} um {new Date(env.PUBLIC_START_DATE).toLocaleString(
|
||||
'de-DE',
|
||||
startTimeOptions
|
||||
)} Uhr.
|
||||
<b>Du hast Dich erfolgreich für Craftattack 7 registriert</b>. Spielstart ist am
|
||||
<span class="underline"
|
||||
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', startDayOptions)}</span
|
||||
>
|
||||
um
|
||||
<span class="underline"
|
||||
>{new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', startTimeOptions)} Uhr</span
|
||||
>.
|
||||
</p>
|
||||
<p>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={env.PUBLIC_PAYPAL_LINK}
|
||||
target="_blank">PayPal</a
|
||||
>
|
||||
tun. Antworten auf häufig gestellte Fragen findest du in unserer
|
||||
<a class="link" href="{env.PUBLIC_BASE_PATH}/faq" target="_blank">FAQ</a>. Außerdem freuen wir
|
||||
uns, dich auf unserem <a class="link" href={env.PUBLIC_TS_LINK} target="_blank">TeamSpeak</a> oder
|
||||
in unserem <a class="link" href={env.PUBLIC_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 value={firstname} size="sm" disabled>
|
||||
{#snippet label()}
|
||||
<span>Vorname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input value={lastname} size="sm" disabled>
|
||||
{#snippet label()}
|
||||
<span>Nachname</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input value={birthday.toISOString().substring(0, 10)} type="date" size="sm" disabled>
|
||||
{#snippet label()}
|
||||
<span>Geburtstag</span>
|
||||
{/snippet}</Input
|
||||
>
|
||||
<Input value={phone} size="sm" disabled>
|
||||
{#snippet label()}
|
||||
<span>Telefonnummer</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Input value={username} size="sm" disabled>
|
||||
{#snippet label()}
|
||||
<span>Spielername</span>
|
||||
{/snippet}
|
||||
</Input>
|
||||
<Select value="edition" size="sm" disabled label="Edition">
|
||||
<option value="edition">{edition}</option>
|
||||
</Select>
|
||||
</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" onclick={close}>Weitere Person anmelden</button>
|
||||
</div>
|
||||
|
25
src/routes/register/schema.ts
Normal file
25
src/routes/register/schema.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
firstname: z
|
||||
.string()
|
||||
.min(
|
||||
2,
|
||||
'Bitte gib Deinen vollständigen Vornamen an, dieser muss mindestens aus 2 Zeichen bestehen'
|
||||
),
|
||||
lastname: z
|
||||
.string()
|
||||
.min(
|
||||
2,
|
||||
'Bitte gib Deinen vollständigen Nachnamen an, dieser muss mindestens aus 2 Zeichen bestehen'
|
||||
),
|
||||
birthday: z.coerce
|
||||
.date()
|
||||
.max(
|
||||
new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
|
||||
'Bitte gib Deinen vollständigen Geburtstag und die korrekte Jahreszahl an. Du musst mindestens 6 Jahre alt sein.'
|
||||
),
|
||||
telephone: z.string().optional(),
|
||||
username: z.string(),
|
||||
playertype: z.enum(['java', 'bedrock', 'noauth'])
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
<div class="h-screen flex justify-center items-center">
|
||||
<h1 class="text-4xl">
|
||||
Reports können nur ingame mittels des <code>/report</code> Befehls erstellt werden
|
||||
</h1>
|
||||
</div>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user