Compare commits

...

63 Commits

Author SHA1 Message Date
a6d910f56a sort feedback desc
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-29 00:19:24 +01:00
fde50d21a6 fuck that shit, revert
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m3s
2024-12-28 03:46:17 +01:00
8ea1750f1a fix scroll position resetting on admin panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m2s
2024-12-28 02:26:43 +01:00
5935b0d561 stick header and filter bar to top
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-28 01:29:05 +01:00
6e7c2eafca fix reported user not updating when changed
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-12-28 00:51:36 +01:00
3c8dc30e43 increase string size for some database fields
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-28 00:44:12 +01:00
8d8b1c52c0 make feedback title optional
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-12-27 19:00:22 +01:00
1596fb605e update report admin endpoint
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2024-12-24 01:18:36 +01:00
7357ad9e88 actually fix strike date not set if status changed but strike reason not
All checks were successful
delpoy / build-and-deploy (push) Successful in 39s
2024-12-20 21:07:36 +01:00
3dd56bc471 fix strike date not set if status changed but strike reason not
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2024-12-20 20:56:38 +01:00
8e60f83b6f fix crashing webhook sending 2024-12-20 20:46:48 +01:00
0280e2a277 fix report details not showing after report is submitted
All checks were successful
delpoy / build-and-deploy (push) Successful in 36s
2024-12-20 19:24:59 +01:00
60f031aa7b update input disabled style
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m5s
2024-12-20 19:10:52 +01:00
e7bba22784 update report submitted window (#42)
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-06 15:00:05 +01:00
a0cc11860f update search component
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-12-05 23:26:30 +01:00
ffc4dcf8f5 do not fail when webhook endpoint doesn't exist 2024-12-05 22:36:03 +01:00
ccdf9b9bed set height and width to 100% on index image
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-03 16:15:40 +01:00
48d3565008 replace png with webp where applicable
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-12-03 15:58:33 +01:00
1d8e99be8a lazy load background image
All checks were successful
delpoy / build-and-deploy (push) Successful in 34s
2024-12-03 14:43:37 +01:00
eb0d0f8db3 fix team page cards overflowing on small width screens
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-03 14:33:41 +01:00
8cb1e8bec5 add admin tools page
All checks were successful
delpoy / build-and-deploy (push) Successful in 40s
2024-12-03 14:04:15 +01:00
9e282cf61b update texts
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-03 12:49:16 +01:00
4dc8d9b646 increase admin panel transition speed
All checks were successful
delpoy / build-and-deploy (push) Successful in 56s
2024-12-03 03:19:54 +01:00
672379c27b update admin layout
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-03 03:12:17 +01:00
332089228e add confirmation popups to admin interface
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-12-02 21:27:13 +01:00
e30446598c use global modal for popup messages
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-02 21:02:23 +01:00
a1965c62e2 fix registration working when uuid cannot be fetched and user already exists
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-12-02 18:17:23 +01:00
4627d3de30 add team website link
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-02 17:29:33 +01:00
63cdf5c33d fix user search only working if table columns got sorted at least once
All checks were successful
delpoy / build-and-deploy (push) Successful in 41s
2024-12-02 16:03:14 +01:00
7872744ab0 fix magnifying glass search not working
All checks were successful
delpoy / build-and-deploy (push) Successful in 36s
2024-12-02 00:35:43 +01:00
95968148a6 update to svelte 5
All checks were successful
delpoy / build-and-deploy (push) Successful in 35s
2024-12-02 00:28:43 +01:00
abffa440a1 skip java uuid request/validation if api sends rate limit response
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-12-01 15:47:07 +01:00
5e07f4d545 rename REPORT_SECRET env variable to API_SECRET
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-12-01 14:48:43 +01:00
06751f0e27 fix report api uuid param
All checks were successful
delpoy / build-and-deploy (push) Successful in 47s
2024-12-01 14:46:23 +01:00
b5e0dfad8c fix menu tooltip being always shown on top
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-12-01 13:30:16 +01:00
b823e198ab always show menu tooltips when on mobile
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-12-01 13:19:14 +01:00
5ff950bcc0 do not overflow report & feedback over footer
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-12-01 00:48:01 +01:00
f9de94db08 expand menu horizontal if screen height is small
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-11-30 16:48:48 +01:00
7ec1d8ab1d request full content when viewing feedback
All checks were successful
delpoy / build-and-deploy (push) Successful in 51s
2024-11-30 16:32:39 +01:00
1ea07f7666 fix sql substr 2024-11-30 16:18:39 +01:00
1e7915837a fix content not showing in admin feedback panel
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-11-30 16:02:45 +01:00
55798fd294 add public feedback/contact option
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m5s
2024-11-30 03:00:46 +01:00
ceaf006dd5 fix rules typo
All checks were successful
delpoy / build-and-deploy (push) Successful in 48s
2024-11-29 14:02:13 +01:00
aacd618d4f trigger pagination if table body is delayed populated
All checks were successful
delpoy / build-and-deploy (push) Successful in 43s
2024-11-29 12:54:54 +01:00
c89cbdd389 fix pagination failing if data is loaded too slow into dom
All checks were successful
delpoy / build-and-deploy (push) Successful in 49s
2024-11-29 12:37:13 +01:00
97f10da146 relocate new user/report button
All checks were successful
delpoy / build-and-deploy (push) Successful in 50s
2024-11-29 02:46:22 +01:00
dc3a404a5b add feedback endpoint (#28) and some other stuff
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m11s
2024-11-29 01:52:19 +01:00
dc86dceb2f redirect to root on unknown report hash 2024-11-29 00:00:50 +01:00
58d39921cb fix pagination missing last entry
All checks were successful
delpoy / build-and-deploy (push) Successful in 54s
2024-11-28 20:15:51 +01:00
f74aa38bef fix pagination not showing on small screens
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-11-28 12:47:45 +01:00
0066736527 probably wasn't overfetching at all
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m24s
2024-11-28 02:42:07 +01:00
750d1b43d7 maybe fix overfetching now
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m23s
2024-11-28 02:31:20 +01:00
f461f6db77 maybe fix overfetching
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m35s
2024-11-28 02:23:15 +01:00
df91278db0 increase pagination prefetch elements
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m27s
2024-11-28 02:13:35 +01:00
bd33727aa6 update admin pagination
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m16s
2024-11-28 01:44:35 +01:00
676bfc23d8 remove unnecessary report api fields
All checks were successful
delpoy / build-and-deploy (push) Successful in 45s
2024-11-27 22:30:03 +01:00
0be3c31b51 include reporter and reported in report api get
All checks were successful
delpoy / build-and-deploy (push) Successful in 52s
2024-11-27 22:28:11 +01:00
b15b74c9b2 update after registration message
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2024-11-27 21:23:26 +01:00
7bb0bd07ac fix invalid faq paypal link
All checks were successful
delpoy / build-and-deploy (push) Successful in 57s
2024-11-27 21:18:45 +01:00
a489b8cdd3 add api endpoint to get all reported related to a user (#27)
All checks were successful
delpoy / build-and-deploy (push) Successful in 55s
2024-11-27 21:13:49 +01:00
6635591788 remove paypal link settings and show link when registered successfully
All checks were successful
delpoy / build-and-deploy (push) Successful in 1m1s
2024-11-27 20:34:07 +01:00
45f8550604 remove menu scroll functionality
All checks were successful
delpoy / build-and-deploy (push) Successful in 59s
2024-11-24 14:29:15 +01:00
865a8eee24 highlight current active page in navbar (#40) 2024-11-24 14:28:42 +01:00
111 changed files with 3989 additions and 2678 deletions

View File

@ -1,6 +1,12 @@
DATABASE_URI=sqlite://./database.db DATABASE_URI=sqlite://./database.db
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=admin ADMIN_PASSWORD=admin
REPORT_SECRET=
PUBLIC_START_DATE=2023-12-26T00:00:00+0200 PUBLIC_START_DATE=2023-12-26T00:00:00+0200
PUBLIC_BASE_PATH= 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

View File

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

View File

@ -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'
}
}
]
};

38
eslint.config.mjs Normal file
View 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'
}
}
);

2746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,33 +15,36 @@
"devDependencies": { "devDependencies": {
"@fontsource/nunito": "^5.1.0", "@fontsource/nunito": "^5.1.0",
"@fontsource/roboto": "^5.1.0", "@fontsource/roboto": "^5.1.0",
"@sveltejs/adapter-node": "^5.2.8", "@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.7.1", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/node": "^22.7.7", "@types/node": "^22.10.1",
"@types/validator": "^13.12.2", "@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.13", "daisyui": "^4.12.14",
"eslint": "^9.13.0", "eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.45.1", "eslint-plugin-svelte": "^2.46.1",
"postcss": "^8.4.47", "globals": "^15.13.0",
"prettier": "^3.3.3", "postcss": "^8.4.49",
"prettier-plugin-svelte": "^3.2.7", "prettier": "^3.4.1",
"sass": "^1.80.3", "prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"publint": "^0.2.12",
"sass": "^1.81.0",
"skinview3d": "^3.1.0", "skinview3d": "^3.1.0",
"svelte": "^4.2.19", "svelte": "^5.3.0",
"svelte-check": "^4.0.5", "svelte-check": "^4.1.0",
"svelte-heros-v2": "^1.3.0", "svelte-heros-v2": "^2.0.1",
"svelte-multicssclass": "^2.1.1", "svelte-multicssclass": "^2.1.1",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.15",
"tslib": "^2.8.0", "tslib": "^2.8.1",
"typescript": "^5.6.3", "typescript": "^5.7.2",
"vite": "^5.4.9", "typescript-eslint": "^8.16.0",
"vitest": "^2.1.3", "vite": "^6.0.1",
"vitest": "^2.1.6",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"type": "module", "type": "module",

View File

@ -2,10 +2,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
// start date in milliseconds. if undefined, start will be Date.now let { start, end }: { start?: number; end: number } = $props();
export let start: number | undefined = undefined;
// end date in milliseconds
export let end: number;
let title = `Spielstart ist am ${new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', { let title = `Spielstart ist am ${new Date(env.PUBLIC_START_DATE).toLocaleString('de-DE', {
day: '2-digit', day: '2-digit',
@ -26,7 +23,7 @@
]; ];
} }
let [days, hours, minutes, seconds] = getUntil(); let [days, hours, minutes, seconds] = $state(getUntil());
let intervalId = setInterval(() => { let intervalId = setInterval(() => {
[days, hours, minutes, seconds] = getUntil(); [days, hours, minutes, seconds] = getUntil();
if (start) start += 1000; if (start) start += 1000;
@ -41,25 +38,25 @@
> >
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}> <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="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{days};" /> <span class="m-auto" style="--value:{days};"></span>
</span> </span>
Tage Tage
</div> </div>
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}> <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="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{hours};" /> <span class="m-auto" style="--value:{hours};"></span>
</span> </span>
Stunden Stunden
</div> </div>
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}> <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="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{minutes};" /> <span class="m-auto" style="--value:{minutes};"></span>
</span> </span>
Minuten Minuten
</div> </div>
<div class="flex flex-col p-2 bg-gray-200 rounded-box bg-opacity-5 backdrop-blur-sm" {title}> <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="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{seconds};" /> <span class="m-auto" style="--value:{seconds};"></span>
</span> </span>
Sekunden Sekunden
</div> </div>

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
export let size = '24'; let { size = '24', fill = 'currentColor' } = $props();
export let fill = 'currentColor';
</script> </script>
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512" <svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
export let size = '24'; let { size = '24', fill = 'currentColor' } = $props();
export let fill = 'currentColor';
</script> </script>
<svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512" <svg xmlns="http://www.w3.org/2000/svg" height={size} width={size} {fill} viewBox="0 0 512 512"

View File

@ -1,12 +1,17 @@
<script lang="ts"> <script lang="ts">
// eslint-disable-next-line no-undef let {
type T = $$Generic; id,
name,
export let id: string | null = null; disabled = false,
export let name: string | null = null; available = {},
export let disabled = false; value = $bindable([])
export let available: string[] | { [key: string]: T } = {}; }: {
export let value: T[] = []; id?: string;
name?: string;
disabled?: boolean;
available?: string[] | { [key: string]: any };
value: any[];
} = $props();
</script> </script>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -15,7 +20,7 @@
{name} {name}
class="select select-bordered select-xs" class="select select-bordered select-xs"
disabled={disabled || available.length === 0} disabled={disabled || available.length === 0}
on:change={(e) => { onchange={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
value.push(Object.values(available)[Object.keys(available).indexOf(e.target.value)]); value.push(Object.values(available)[Object.keys(available).indexOf(e.target.value)]);
@ -42,7 +47,7 @@
<button <button
{disabled} {disabled}
class:pointer-events-none={disabled} class:pointer-events-none={disabled}
on:click={() => { onclick={() => {
value.splice(i, 1); value.splice(i, 1);
value = value; value = value;
}}></button }}></button

View File

@ -1,29 +1,46 @@
<script lang="ts"> <script lang="ts">
import { Eye, EyeSlash } from 'svelte-heros-v2'; import { Eye, EyeSlash } from 'svelte-heros-v2';
import { createEventDispatcher } from 'svelte'; import type { Snippet } from 'svelte';
export let id: string | null = null; let {
export let name: string | null = null; label,
export let type = 'text'; notice,
export let value: string | null = null; id,
export let placeholder: string | null = null; name,
export let pattern: RegExp | null = null; type = 'text',
export let required = false; value = $bindable(),
export let disabled = false; placeholder,
export let readonly = false; pattern,
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md'; required = false,
export let pickyWidth = true; disabled = false,
export let containerClass = ''; readonly = false,
checked = $bindable(false),
export let inputElement: HTMLInputElement | undefined = undefined; size = 'md',
pickyWidth = true,
const dispatch = createEventDispatcher(); containerClass = '',
function input(e: Event & { currentTarget: EventTarget & HTMLInputElement }) { inputElement = $bindable(),
dispatch('input', e); oninput,
} onclick
function click(e: Event) { }: {
dispatch('click', e); 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; let initialType = type;
@ -49,15 +66,15 @@
{disabled} {disabled}
bind:value bind:value
bind:this={inputElement} bind:this={inputElement}
on:input={input} {oninput}
on:click={click} {onclick}
/> />
{:else} {:else}
<div> <div>
{#if $$slots.label} {#if label}
<label class="label" for={id}> <label class="label" for={id}>
<span class="label-text"> <span class="label-text">
<slot name="label" /> {@render label()}
{#if required} {#if required}
<span class="text-red-700">*</span> <span class="text-red-700">*</span>
{/if} {/if}
@ -81,41 +98,40 @@
class:input-lg={type !== 'checkbox' && size === 'lg'} class:input-lg={type !== 'checkbox' && size === 'lg'}
class:input-bordered={type !== 'checkbox'} class:input-bordered={type !== 'checkbox'}
class:pr-11={initialType === 'password'} class:pr-11={initialType === 'password'}
class:!border-none,!text-inherit={disabled}
{id} {id}
{name} {name}
{type} {type}
{value} {value}
{checked}
{placeholder} {placeholder}
{required} {required}
{disabled} {disabled}
{readonly} {readonly}
bind:this={inputElement} bind:this={inputElement}
autocomplete="off" autocomplete="off"
on:input={(e) => { onchange={() => {
value = e.target?.value; if (type === 'checkbox') {
if (pattern && !pattern.test(e.target?.value)) { checked = !checked;
if (inputElement?.value.endsWith(e.data)) {
value = e.target?.value.substring(0, e.target?.value.length - e.data.length);
}
return;
} }
return input(e);
}} }}
on:paste={(e) => { oninput={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
if ( value = e.currentTarget.value;
pattern && if (pattern && !pattern.test(value)) return;
!pattern.test((e.clipboardData || window.clipboardData).getData('text')) oninput?.(e);
) { }}
onpaste={(e) => {
if (pattern && e.clipboardData && !pattern.test(e.clipboardData.getData('text'))) {
e.preventDefault(); e.preventDefault();
} }
}} }}
on:click={click} {onclick}
/> />
{#if initialType === 'password'} {#if initialType === 'password'}
<button <button
class="absolute right-3" class="absolute right-3"
type="button" type="button"
on:click={() => { onclick={() => {
type = type === 'password' ? 'text' : 'password'; type = type === 'password' ? 'text' : 'password';
}} }}
> >
@ -127,9 +143,11 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if $$slots.notice} {#if notice}
<label class="label" for={id}> <label class="label" for={id}>
<span class="label-text-alt"><slot name="notice" /></span> <span class="label-text-alt">
{@render notice()}
</span>
</label> </label>
{/if} {/if}
</div> </div>

View File

@ -1,23 +1,30 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; 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();
export let id: string | null = null; let elemValue = $state(value);
export let value = ''; let searchSuggestions: { name: string; value: string }[] = $state([]);
export let inputValue = '';
export let suggestionRequired = false;
export let emptyAllowed = false;
export let searchSuggestionFunc: (
input: string
) => Promise<{ name: string; value: string }[]> = () => Promise.resolve([]);
export let invalidMessage: string | null = null;
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
export let label: string | null = null;
export let required = false;
const dispatch = createEventDispatcher();
let searchSuggestions: { name: string; value: string }[] = [];
$: if (!suggestionRequired) value = inputValue;
</script> </script>
<div class="relative"> <div class="relative">
@ -34,6 +41,7 @@
{/if} {/if}
<input <input
type="search" type="search"
autocomplete="off"
class="input input-bordered w-full" class="input input-bordered w-full"
class:input-xs={size === 'xs'} class:input-xs={size === 'xs'}
class:input-sm={size === 'sm'} class:input-sm={size === 'sm'}
@ -41,44 +49,44 @@
class:input-lg={size === 'lg'} class:input-lg={size === 'lg'}
{id} {id}
{required} {required}
bind:value={inputValue} bind:value={elemValue}
on:input={(e) => { oninput={async (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
value = ''; searchSuggestions = await searchSuggestionFunc(elemValue);
searchSuggestionFunc(inputValue).then((v) => { const searchSuggestion = searchSuggestions.find((v) => v.name === elemValue);
searchSuggestions = v; if (searchSuggestion !== undefined) {
const searchSuggestion = v.find((v) => v.name === inputValue); elemValue = searchSuggestion.name;
if (searchSuggestion !== undefined) { value = searchSuggestion.value;
inputValue = searchSuggestion.name; searchSuggestions = [];
value = searchSuggestion.value; (e.currentTarget || e.target).setCustomValidity('');
searchSuggestions = []; onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
e.target?.setCustomValidity(''); } else if (elemValue === '' && emptyAllowed) {
dispatch('submit', { input: inputValue, value: value }); onsubmit?.(Object.assign(e, { input: '', value: '' }));
} else if (inputValue === '' && emptyAllowed) { } else {
dispatch('submit', { input: '', value: '' }); value = '';
} }
});
}} }}
on:invalid={(e) => { oninvalid={(e: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
if (invalidMessage != null) e.target?.setCustomValidity(invalidMessage); if (invalidMessage) e.currentTarget.setCustomValidity(invalidMessage);
}} }}
onfocus={() => searchSuggestionFunc(elemValue).then((v) => (searchSuggestions = v))}
pattern={suggestionRequired pattern={suggestionRequired
? `${value ? inputValue : 'a^' + (emptyAllowed ? '|$^' : '')}` ? `${value ? elemValue : 'a^' + (emptyAllowed ? '|$^' : '')}`
: null} : null}
/> />
</div> </div>
{#if inputValue && searchSuggestions.length !== 0} {#if elemValue && searchSuggestions.length !== 0}
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box"> <ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
{#each searchSuggestions as searchSuggestion} {#each searchSuggestions as searchSuggestion}
<li class="w-full text-left"> <li class="w-full text-left">
<button <button
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap" class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title="{searchSuggestion.name} ({searchSuggestion.value})" title="{searchSuggestion.name} ({searchSuggestion.value})"
on:click|preventDefault={() => { onclick={(e) => {
inputValue = searchSuggestion.name; elemValue = searchSuggestion.name;
value = searchSuggestion.value; value = searchSuggestion.value;
searchSuggestions = []; searchSuggestions = [];
dispatch('submit', { input: inputValue, value: value }); onsubmit?.(Object.assign(e, { input: elemValue, value: value }));
}}>{searchSuggestion.name}</button }}>{searchSuggestion.name}</button
> >
</li> </li>
@ -88,9 +96,10 @@
</div> </div>
<!-- close the search suggestions box when clicking outside --> <!-- close the search suggestions box when clicking outside -->
{#if inputValue && searchSuggestions.length !== 0} {#if elemValue && searchSuggestions.length !== 0}
<button <button
aria-label=" "
class="absolute top-0 left-0 z-10 w-full h-full cursor-default" class="absolute top-0 left-0 z-10 w-full h-full cursor-default"
on:click={() => (searchSuggestions = [])} onclick={() => (searchSuggestions = [])}
/> ></button>
{/if} {/if}

View File

@ -1,20 +1,31 @@
<script lang="ts"> <script lang="ts">
// eslint-disable-next-line no-undef import type { Snippet } from 'svelte';
import { createEventDispatcher } from 'svelte';
type T = $$Generic; let {
children,
export let id: string | null = null; id,
export let name: string | null = null; name,
export let value: T | null = null; value = $bindable(),
export let label: string | null = null; label,
export let notice: string | null = null; notice,
export let required = false; required = false,
export let disabled = false; disabled = false,
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md'; size = 'md',
export let pickyWidth = true; pickyWidth = true,
onChange
let dispatch = createEventDispatcher(); }: {
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> </script>
<div> <div>
@ -35,14 +46,15 @@
class:select-sm={size === 'sm'} class:select-sm={size === 'sm'}
class:select-md={size === 'md'} class:select-md={size === 'md'}
class:select-lg={size === 'lg'} class:select-lg={size === 'lg'}
class:!border-none,!text-inherit={disabled}
{id} {id}
{name} {name}
{required} {required}
{disabled} {disabled}
bind:value bind:value
on:change={(e) => dispatch('change', { value: value })} onchange={() => onChange && onChange({ value: value })}
> >
<slot /> {@render children()}
</select> </select>
{#if notice} {#if notice}
<label class="label" for={id}> <label class="label" for={id}>

View File

@ -1,18 +1,27 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; let {
id,
export let id: string | null = null; name,
export let name: string | null = null; value = $bindable(),
export let value: string | null = null; label,
export let label: string | null = null; notice,
export let notice: string | null = null; required,
export let required = false; disabled,
export let disabled = false; readonly,
export let readonly = false; size = 'md',
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md'; rows = 2
export let rows = 2; }: {
id?: string;
const dispatch = createEventDispatcher(); name?: string;
value?: string;
label?: string;
notice?: string;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
rows?: number;
} = $props();
</script> </script>
<div> <div>
@ -27,12 +36,12 @@
</label> </label>
{/if} {/if}
<textarea <textarea
class="textarea w-full" class="textarea textarea-bordered w-full"
class:textarea-xs={size === 'xs'} class:textarea-xs={size === 'xs'}
class:textarea-sm={size === 'sm'} class:textarea-sm={size === 'sm'}
class:textarea-md={size === 'md'} class:textarea-md={size === 'md'}
class:textarea-lg={size === 'lg'} class:textarea-lg={size === 'lg'}
class:textarea-bordered={!readonly} class:!border-none,!text-inherit={disabled}
{id} {id}
{name} {name}
{required} {required}
@ -40,8 +49,7 @@
{readonly} {readonly}
{rows} {rows}
bind:value bind:value
on:click={(e) => dispatch('click', e)} ></textarea>
/>
{#if notice} {#if notice}
<label class="label" for={id}> <label class="label" for={id}>
<span class="label-text-alt">{notice}</span> <span class="label-text-alt">{notice}</span>

View File

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

View File

@ -1,17 +1,20 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, getContext, onDestroy } from 'svelte'; import { getContext, onDestroy, type Snippet } from 'svelte';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { ChevronDown, ChevronUp } from 'svelte-heros-v2'; import { ChevronDown, ChevronUp } from 'svelte-heros-v2';
let { children, onSort }: { children: Snippet; onSort: ({ asc }: { asc: boolean }) => void } =
$props();
let id = crypto.randomUUID(); let id = crypto.randomUUID();
let asc = false;
let asc = $state(false);
let { ascHeader } = getContext('sortableTr') as { ascHeader: Writable<null | string> }; let { ascHeader } = getContext('sortableTr') as { ascHeader: Writable<null | string> };
ascHeader.subscribe((v) => { ascHeader.subscribe((v) => {
if (v !== id) asc = false; if (v !== id) asc = false;
}); });
let dispatch = createEventDispatcher();
onDestroy(() => { onDestroy(() => {
if ($ascHeader === id) $ascHeader = null; if ($ascHeader === id) $ascHeader = null;
}); });
@ -20,12 +23,14 @@
<th> <th>
<button <button
class="flex flex-center" class="flex flex-center"
on:click={() => { onclick={() => {
dispatch('sort', { asc: (asc = !asc) }); onSort({ asc: (asc = !asc) });
$ascHeader = id; $ascHeader = id;
}} }}
> >
<span class="mr-1"><slot /></span> <span class="mr-1">
{@render children()}
</span>
{#if $ascHeader === id && asc} {#if $ascHeader === id && asc}
<ChevronUp variation="solid" /> <ChevronUp variation="solid" />
{:else} {:else}

View File

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { setContext } from 'svelte'; import { setContext, type Snippet } from 'svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
let { children, ...restProps }: { children: Snippet; [x: string]: unknown } = $props();
setContext('sortableTr', { setContext('sortableTr', {
ascHeader: writable(null) ascHeader: writable(null)
}); });
</script> </script>
<tr {...$$restProps}> <tr {...restProps}>
<slot /> {@render children()}
</tr> </tr>

View File

@ -3,15 +3,10 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
export let timeout = 2000; let { children, timeout = 2000, show = false } = $props();
export let show = false;
export function reset() { let progressValue = $state(100);
progressValue = 1; let intervalClear: ReturnType<typeof setInterval> | undefined = $state();
}
let progressValue = 100;
let intervalClear: ReturnType<typeof setInterval> | undefined;
function startTimout() { function startTimout() {
intervalClear = setInterval(() => { intervalClear = setInterval(() => {
@ -23,10 +18,11 @@
}, timeout / 100); }, timeout / 100);
} }
$: if (show) { $effect(() => {
if (!show) return;
progressValue = 0; progressValue = 0;
startTimout(); startTimout();
} });
onDestroy(() => clearInterval(intervalClear)); onDestroy(() => clearInterval(intervalClear));
</script> </script>
@ -36,23 +32,23 @@
in:fly={{ x: 0, duration: 200 }} in:fly={{ x: 0, duration: 200 }}
out:fly={{ x: 400, duration: 400 }} out:fly={{ x: 400, duration: 400 }}
class="toast" class="toast"
on:mouseenter={() => { onmouseenter={() => {
clearInterval(intervalClear); clearInterval(intervalClear);
progressValue = 1; progressValue = 1;
}} }}
on:mouseleave={startTimout} onmouseleave={startTimout}
role="alert" role="alert"
> >
<div class="alert alert-error border-none relative text-gray-900 overflow-hidden"> <div class="alert alert-error border-none relative text-gray-900 overflow-hidden">
<div class="flex gap-2 z-10"> <div class="flex gap-2 z-10">
<ExclamationCircle /> <ExclamationCircle />
<slot /> {@render children()}
</div> </div>
<progress <progress
class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]" class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]"
value={progressValue} value={progressValue}
max="100" max="100"
/> ></progress>
</div> </div>
</div> </div>
{/if} {/if}

18
src/lib/context.ts Normal file
View 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;
}

View File

@ -1,12 +1,10 @@
export class Permissions { export class Permissions {
static readonly AdminRead = 2; static readonly Admin = 2 << 0;
static readonly AdminWrite = 4; static readonly Users = 2 << 1;
static readonly UserRead = 8; static readonly Reports = 2 << 2;
static readonly UserWrite = 16; static readonly Feedback = 2 << 3;
static readonly ReportRead = 32; static readonly Settings = 2 << 4;
static readonly ReportWrite = 64; static readonly Tools = 2 << 5;
static readonly SettingsRead = 128;
static readonly SettingsWrite = 256;
readonly value: number; readonly value: number;
@ -30,40 +28,33 @@ export class Permissions {
static allPermissions(): number[] { static allPermissions(): number[] {
return [ return [
Permissions.AdminRead, Permissions.Admin,
Permissions.AdminWrite, Permissions.Users,
Permissions.UserRead, Permissions.Reports,
Permissions.UserWrite, Permissions.Feedback,
Permissions.ReportRead, Permissions.Settings,
Permissions.ReportWrite, Permissions.Tools
Permissions.SettingsRead,
Permissions.SettingsWrite
]; ];
} }
adminRead(): boolean { admin(): boolean {
return (this.value & Permissions.AdminRead) != 0; return (this.value & Permissions.Admin) != 0;
} }
adminWrite(): boolean {
return (this.value & Permissions.AdminWrite) != 0; users(): boolean {
return (this.value & Permissions.Users) != 0;
} }
userRead(): boolean { reports(): boolean {
return (this.value & Permissions.UserRead) != 0; return (this.value & Permissions.Reports) != 0;
} }
userWrite(): boolean { feedback(): boolean {
return (this.value & Permissions.UserWrite) != 0; return (this.value & Permissions.Reports) != 0;
} }
reportRead(): boolean { settings(): boolean {
return (this.value & Permissions.ReportRead) != 0; return (this.value & Permissions.Reports) != 0;
} }
reportWrite(): boolean { tools(): boolean {
return (this.value & Permissions.ReportWrite) != 0; return (this.value & Permissions.Tools) != 0;
}
settingsRead(): boolean {
return (this.value & Permissions.SettingsRead) != 0;
}
settingsWrite(): boolean {
return (this.value & Permissions.SettingsWrite) != 0;
} }
asArray(): number[] { asArray(): number[] {

View File

@ -45,7 +45,7 @@ export const rulesShort = {
Das Verkaufen von Items ist allgemein jedem Spieler überall gestattet. Jedoch bietet es sich an und ist 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 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 Interaktion zu fördern. Ein angemessener Abstand der privaten Strukturen vom Shoppingdisrict ist
inzuhalten. einzuhalten.
` `
}, },
{ {

View File

@ -24,16 +24,16 @@ export class User extends Model {
@Column({ type: DataTypes.DATE, allowNull: false }) @Column({ type: DataTypes.DATE, allowNull: false })
declare birthday: Date; declare birthday: Date;
@Column({ type: DataTypes.STRING }) @Column({ type: DataTypes.STRING })
declare telephone: string; declare telephone: string | null;
@Column({ type: DataTypes.STRING, allowNull: false }) @Column({ type: DataTypes.STRING, allowNull: false })
declare username: string; declare username: string;
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'noauth'), allowNull: false }) @Column({ type: DataTypes.ENUM('java', 'bedrock', 'noauth'), allowNull: false })
declare playertype: 'java' | 'bedrock' | 'noauth'; declare playertype: 'java' | 'bedrock' | 'noauth';
@Column({ type: DataTypes.STRING }) @Column({ type: DataTypes.STRING })
declare password: string; declare password: string | null;
@Column({ type: DataTypes.UUID, unique: true }) @Column({ type: DataTypes.UUID, unique: true })
@Index @Index
declare uuid: string; declare uuid: string | null;
} }
@Table({ modelName: 'report', underscored: true }) @Table({ modelName: 'report', underscored: true })
@ -43,40 +43,51 @@ export class Report extends Model {
declare url_hash: string; declare url_hash: string;
@Column({ type: DataTypes.STRING, allowNull: false }) @Column({ type: DataTypes.STRING, allowNull: false })
declare subject: string; declare subject: string;
@Column({ type: DataTypes.STRING }) @Column({ type: DataTypes.TEXT })
declare body: string; declare body: string | null;
@Column({ type: DataTypes.BOOLEAN, allowNull: false }) @Column({ type: DataTypes.BOOLEAN, allowNull: false })
declare draft: boolean; declare draft: boolean;
@Column({ type: DataTypes.ENUM('none', 'review', 'reviewed'), allowNull: false }) @Column({ type: DataTypes.ENUM('none', 'review', 'reviewed'), allowNull: false })
declare status: 'none' | 'review' | 'reviewed'; declare status: 'none' | 'review' | 'reviewed';
@Column({ type: DataTypes.STRING }) @Column({ type: DataTypes.STRING })
declare notice: string; declare notice: string | null;
@Column({ type: DataTypes.STRING }) @Column({ type: DataTypes.TEXT })
declare statement: string; declare statement: string | null;
@Column({ type: DataTypes.DATE })
declare striked_at: Date | null;
@Column({ type: DataTypes.INTEGER, allowNull: false }) @Column({ type: DataTypes.INTEGER, allowNull: false })
@ForeignKey(() => User) @ForeignKey(() => User)
declare reporter_id: number; declare reporter_id: number;
@Column({ type: DataTypes.INTEGER }) @Column({ type: DataTypes.INTEGER })
@ForeignKey(() => User) @ForeignKey(() => User)
declare reported_id: number; declare reported_id: number | null;
@Column({ type: DataTypes.INTEGER }) @Column({ type: DataTypes.INTEGER })
@ForeignKey(() => Admin) @ForeignKey(() => Admin)
declare auditor_id: number; declare auditor_id: number | null;
@Column({ type: DataTypes.INTEGER }) @Column({ type: DataTypes.INTEGER })
@ForeignKey(() => StrikeReason) @ForeignKey(() => StrikeReason)
declare strike_reason_id: number | null; declare strike_reason_id: number | null;
@BelongsTo(() => User, 'reporter_id') @BelongsTo(() => User, {
declare reporter: User; onDelete: 'CASCADE',
@BelongsTo(() => User, 'reported_id') foreignKey: 'reporter_id'
declare reported: User; })
@BelongsTo(() => Admin, 'auditor_id') declare reporter: User | null;
declare auditor: Admin; @BelongsTo(() => User, {
@BelongsTo(() => StrikeReason, 'strike_reason_id') onDelete: 'CASCADE',
declare strike_reason: StrikeReason; foreignKey: 'reported_id'
})
@Column({ type: DataTypes.DATE }) declare reported: User | null;
declare striked_at: Date | 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 }) @Table({ modelName: 'strike_reason', underscored: true, createdAt: false, updatedAt: false })
@ -97,6 +108,28 @@ export class StrikePunishment extends Model {
declare punishment_in_seconds: number; 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 }) @Table({ modelName: 'admin', underscored: true })
export class Admin extends Model { export class Admin extends Model {
@Column({ type: DataTypes.STRING, allowNull: false, unique: true }) @Column({ type: DataTypes.STRING, allowNull: false, unique: true })
@ -136,7 +169,6 @@ export class Settings extends Model {
@Column({ @Column({
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(this: Settings): any { get(this: Settings): any {
const value = this.getDataValue('value'); const value = this.getDataValue('value');
return value != null ? JSON.parse(value) : null; return value != null ? JSON.parse(value) : null;
@ -148,5 +180,5 @@ export class Settings extends Model {
export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, { export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, {
// only log sql queries in dev mode // only log sql queries in dev mode
logging: dev ? console.log : false, logging: dev ? console.log : false,
models: [User, Report, StrikeReason, StrikePunishment, Admin, Settings] models: [User, Report, StrikeReason, StrikePunishment, Feedback, Admin, Settings]
}); });

View File

@ -9,11 +9,16 @@ export class UserNotFoundError extends Error {
} }
} }
export class RateLimitError extends Error {}
export class ApiError extends Error {} export class ApiError extends Error {}
export async function getJavaUuid(username: string): Promise<string> { export async function getJavaUuid(username: string): Promise<string> {
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`); const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
if (!response.ok) { if (!response.ok) {
if (response.status == 429) {
throw new RateLimitError();
}
throw response.status < 500 throw response.status < 500
? new UserNotFoundError(username) ? new UserNotFoundError(username)
: new ApiError(`mojang server error (${response.status}): ${await response.text()}`); : new ApiError(`mojang server error (${response.status}): ${await response.text()}`);

View File

@ -1,11 +1,15 @@
export async function webhookUserReported(endpoint: string, uuid: string) { export async function webhookUserReported(endpoint: string, uuid: string) {
await fetch(endpoint, { try {
method: 'POST', await fetch(endpoint, {
headers: { method: 'POST',
'Content-Type': 'application/json' headers: {
}, 'Content-Type': 'application/json'
body: JSON.stringify({ },
user: uuid body: JSON.stringify({
}) user: uuid
}); })
});
} catch (e) {
throw (e as { message: string }).message;
}
} }

View File

@ -13,4 +13,5 @@ export const errorMessage: Writable<string | null> = (() => {
}; };
})(); })();
export const reportCount: Writable<number> = writable(0); export const reportCount: Writable<number> = writable(0);
export const feedbackCount: Writable<number> = writable(0);
export const adminCount: Writable<number> = writable(0); export const adminCount: Writable<number> = writable(0);

View File

@ -3,84 +3,89 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/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', 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}/`, href: `${env.PUBLIC_BASE_PATH}/`,
active: false active: false
}, },
{ {
name: 'Registrieren', 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`, href: `${env.PUBLIC_BASE_PATH}/register`,
active: false active: false
}, },
{ {
name: 'Regeln', 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`, href: `${env.PUBLIC_BASE_PATH}/rules`,
active: false active: false
}, },
{ {
name: 'FAQ', name: 'FAQ',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-faq.png`, sprite: `${env.PUBLIC_BASE_PATH}/img/menu-faq.webp`,
href: `${env.PUBLIC_BASE_PATH}/faq`, href: `${env.PUBLIC_BASE_PATH}/faq`,
active: false active: false
}, },
{
name: 'Feedback & Kontakt',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-feedback.webp`,
href: `${env.PUBLIC_BASE_PATH}/feedback`,
active: false
},
{ {
name: 'Team', name: 'Team',
sprite: `${env.PUBLIC_BASE_PATH}/img/menu-team.png`, sprite: `${env.PUBLIC_BASE_PATH}/img/menu-team.webp`,
href: `${env.PUBLIC_BASE_PATH}/team`, href: `${env.PUBLIC_BASE_PATH}/team`,
active: false active: false
} }
]; ]);
let showMenuPermanent = $state(false);
let onAdminPage = $state(false);
let isTouch = $state(false);
let windowHeight = $state(0);
let showMenuPermanent = false; $effect(() => {
let menuButtonScrollIndex: number | null = null; onAdminPage =
function onMenuButtonScroll(e: WheelEvent) { $page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
if (menuButtonScrollIndex == null) { $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
if (e.deltaY < 0) { });
menuButtonScrollIndex = navPaths.length - 1; $effect(() => {
} else if (e.deltaY > 0) { for (let i = 0; i < navPaths.length; i++) {
menuButtonScrollIndex = 0; navPaths[i].active = navPaths[i].href === $page.url.pathname;
} 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 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 onAdminPage = false; let navElem: HTMLDivElement;
$: onAdminPage = let popupModalElem: HTMLDialogElement;
$page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
$page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
let isTouch = false;
let nav: HTMLDivElement;
</script> </script>
<svelte:window bind:innerHeight={windowHeight} />
<svelte:body <svelte:body
on:touchend={(e) => { on:touchend={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
if (isTouch && !nav.contains(e.target)) showMenuPermanent = false; if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
}} }}
/> />
@ -88,26 +93,26 @@
{#if !onAdminPage} {#if !onAdminPage}
<meta property="og:url" content={$page.url.toString()} /> <meta property="og:url" content={$page.url.toString()} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content="{env.PUBLIC_BASE_PATH}/img/logo-512.png" /> <meta property="og:image" content="{env.PUBLIC_BASE_PATH}/img/logo-512.webp" />
{/if} {/if}
</svelte:head> </svelte:head>
<main> <main>
<div class="min-h-[calc(100vh-3.5rem)] h-full w-full" class:min-h-screen={onAdminPage}> <div class="min-h-[calc(100vh-3.5rem)] h-full w-full" class:min-h-screen={onAdminPage}>
<slot /> {@render children()}
</div> </div>
</main> </main>
<nav> <nav>
<div <div
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50" 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} class:hidden={onAdminPage}
bind:this={nav} bind:this={navElem}
> >
<button <button
class={isTouch class={isTouch
? 'btn btn-square relative w-16 h-16' ? 'btn btn-square relative w-16 h-16'
: 'btn btn-square group/menu-button relative w-16 h-16'} : 'btn btn-square group/menu-button relative w-16 h-16'}
on:click={() => { onclick={() => {
if (!isTouch) { if (!isTouch) {
let activePath = navPaths.find((path) => path.active); let activePath = navPaths.find((path) => path.active);
if (activePath !== undefined) { if (activePath !== undefined) {
@ -116,27 +121,20 @@
showMenuPermanent = !showMenuPermanent; showMenuPermanent = !showMenuPermanent;
} }
}} }}
on:touchend={() => { ontouchend={() => {
isTouch = true; isTouch = true;
showMenuPermanent = !showMenuPermanent; showMenuPermanent = !showMenuPermanent;
}} }}
on:mouseleave={() => {
if (menuButtonScrollIndex !== null) {
navPaths[menuButtonScrollIndex].active = false;
}
menuButtonScrollIndex = null;
}}
on:wheel|preventDefault={onMenuButtonScroll}
> >
<img <img
class="absolute w-full h-full p-1 pixelated" 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" alt="menu"
/> />
<img <img
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated" 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} 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" alt="menu hover"
/> />
</button> </button>
@ -144,29 +142,33 @@
class:hidden={!showMenuPermanent} class:hidden={!showMenuPermanent}
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'} 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} {#each navPaths as navPath, i}
<li <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} data-tip={navPath.name}
> >
<a <a
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center" class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
href={navPath.href} href={navPath.href}
on:click={() => goto(navPath.href)} onclick={() => goto(navPath.href)}
> >
<div <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;" 3.5}rem 0;"
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated" 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"> <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} /> <img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
</div> </div>
<img <img
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10" 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} 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" alt="menu hover"
/> />
</a> </a>
@ -183,7 +185,7 @@
class:hidden={onAdminPage} class:hidden={onAdminPage}
> >
<div class="hidden sm:block"> <div class="hidden sm:block">
<p>© {new Date().getFullYear()} MHSL Craftattack Team</p> <p>© {new Date().getFullYear()} mhsl.eu</p>
</div> </div>
<div class="flex gap-4"> <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/id.html" target="_blank">Impressum</a>
@ -192,3 +194,65 @@
</div> </div>
</footer> </footer>
{/if} {/if}
<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>
<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,.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>

View File

@ -4,7 +4,8 @@
import { Clock, User, WrenchScrewdriver } from 'svelte-heros-v2'; import { Clock, User, WrenchScrewdriver } from 'svelte-heros-v2';
import Crosshairs from '$lib/components/CustomIcons/Crosshairs.svelte'; import Crosshairs from '$lib/components/CustomIcons/Crosshairs.svelte';
import Skull from '$lib/components/CustomIcons/Skull.svelte'; import Skull from '$lib/components/CustomIcons/Skull.svelte';
import type { PageData } from './$types';
let { data } = $props();
let information = [ let information = [
{ {
@ -23,8 +24,6 @@
'Jeder ist willkommen und kann mitspielen. Dazu benötigst Du nur einen Minecraft-Account und schon bist Du Teil unser Community :)' 'Jeder ist willkommen und kann mitspielen. Dazu benötigst Du nur einen Minecraft-Account und schon bist Du Teil unser Community :)'
} }
]; ];
export let data: PageData;
</script> </script>
<svelte:head> <svelte:head>
@ -39,7 +38,7 @@
<img src="{env.PUBLIC_BASE_PATH}/img/craftattack.webp" alt="Craftattack 7" /> <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 class="flex flex-col gap-5 lg:gap-14 w-full mt-2 lg:mt-5 lg:w-10/12 h-full">
<div> <div>
<div class="divider" /> <div class="divider"></div>
<div class="flex flex-col md:flex-row xl:flex-col gap-5"> <div class="flex flex-col md:flex-row xl:flex-col gap-5">
{#each information as info} {#each information as info}
<div> <div>
@ -48,7 +47,7 @@
</div> </div>
{/each} {/each}
</div> </div>
<div class="divider" /> <div class="divider"></div>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<a <a
@ -64,7 +63,13 @@
class="hidden xl:block absolute top-0 left-0 h-full w-full" 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%);" style="clip-path: polygon(60% 0, 100% 0, 100% 100%, 40% 100%);"
> >
<img src="{env.PUBLIC_BASE_PATH}/img/bg.webp" alt="" /> <img
src="{env.PUBLIC_BASE_PATH}/img/bg.webp"
alt=""
loading="lazy"
width="100%"
height="100%"
/>
</div> </div>
<div class="hidden xl:flex justify-center absolute bottom-12 right-0 w-[60%]"> <div class="hidden xl:flex justify-center absolute bottom-12 right-0 w-[60%]">
<Countdown end={Date.parse(env.PUBLIC_START_DATE)} /> <Countdown end={Date.parse(env.PUBLIC_START_DATE)} />
@ -76,7 +81,7 @@
<h2 class="text-3xl text-black dark:text-white mb-8">Über uns</h2> <h2 class="text-3xl text-black dark:text-white mb-8">Über uns</h2>
<p> <p>
Wir sind ein kleines <a class="link" href={`${env.PUBLIC_BASE_PATH}/team`}>Team</a> von Minecraft-Enthusiasten, Wir sind ein kleines <a class="link" href={`${env.PUBLIC_BASE_PATH}/team`}>Team</a> von Minecraft-Enthusiasten,
das bereits im 6. Jahr in Folge Minecraft CraftAttack organisiert. Jahr für Jahr arbeiten wir 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. daran, das Spielerlebnis zu verbessern und steigeren die Teilnehmerzahl.
</p> </p>
<p> <p>
@ -96,7 +101,7 @@
</div> </div>
<div class="flex flex-col xl:flex-row justify-center items-center py-20 bg-base-100"> <div class="flex flex-col xl:flex-row justify-center items-center py-20 bg-base-100">
<div> <div>
<h3 class="text-center text-2xl mb-6">2023 in Zahlen</h3> <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="flex flex-col lg:flex-row gap-4">
<div class="stats stats-vertical xl:stats-horizontal shadow"> <div class="stats stats-vertical xl:stats-horizontal shadow">
<div class="stat"> <div class="stat">

View File

@ -1,8 +1,9 @@
import type { LayoutServerLoad } from './$types'; 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 { getSession } from '$lib/server/session';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { Op } from 'sequelize';
export const load: LayoutServerLoad = async ({ route, cookies }) => { export const load: LayoutServerLoad = async ({ route, cookies }) => {
const session = getSession(cookies); const session = getSession(cookies);
@ -11,12 +12,16 @@ export const load: LayoutServerLoad = async ({ route, cookies }) => {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin/login`); throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin/login`);
return { return {
userCount: session?.permissions.userRead() ? await User.count() : null, userCount: session?.permissions.users() ? await User.count() : null,
reportCount: session?.permissions.reportRead() reportCount: session?.permissions.reports()
? await Report.count({ where: { draft: false, status: ['none', 'review'] } }) ? await Report.count({ where: { draft: false, status: ['none', 'review'] } })
: null, : null,
adminCount: session?.permissions.adminRead() ? await Admin.count() : null, feedbackCount: session?.permissions.feedback()
settingsRead: session?.permissions.settingsRead(), ? 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 self: session
? JSON.parse(JSON.stringify(await Admin.findOne({ where: { id: session.userId } }))) ? JSON.parse(JSON.stringify(await Admin.findOne({ where: { id: session.userId } })))
: null : null

View File

@ -1,13 +1,25 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { fly } from 'svelte/transition';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { ArrowLeftOnRectangle, Cog6Tooth, Flag, UserGroup, Users } from 'svelte-heros-v2'; import {
ArrowLeftOnRectangle,
AdjustmentsHorizontal,
Flag,
UserGroup,
Users,
BookOpen,
WrenchScrewdriver
} from 'svelte-heros-v2';
import { buttonTriggeredRequest } from '$lib/components/utils'; import { buttonTriggeredRequest } from '$lib/components/utils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { LayoutData } from './$types'; import { adminCount, errorMessage, reportCount, feedbackCount } from '$lib/stores';
import { adminCount, errorMessage, reportCount } from '$lib/stores';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte'; import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
let { children, data } = $props();
let transitionPrefix = $state(0);
async function logout() { async function logout() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/logout`, { const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/logout`, {
method: 'POST' method: 'POST'
@ -19,8 +31,8 @@
} }
} }
export let data: LayoutData;
if (data.reportCount) $reportCount = data.reportCount; if (data.reportCount) $reportCount = data.reportCount;
if (data.feedbackCount) $feedbackCount = data.feedbackCount;
if (data.adminCount) $adminCount = data.adminCount; if (data.adminCount) $adminCount = data.adminCount;
let tabs = [ let tabs = [
@ -38,6 +50,13 @@
badge: $reportCount, badge: $reportCount,
enabled: data.reportCount != null 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`, path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
icon: Users, icon: Users,
@ -47,18 +66,24 @@
}, },
{ {
path: `${env.PUBLIC_BASE_PATH}/admin/settings`, path: `${env.PUBLIC_BASE_PATH}/admin/settings`,
icon: Cog6Tooth, icon: AdjustmentsHorizontal,
name: 'Website Einstellungen', name: 'Website Einstellungen',
badge: null, badge: null,
enabled: data.settingsRead enabled: data.settingsRead
},
{
path: `${env.PUBLIC_BASE_PATH}/admin/tools`,
icon: WrenchScrewdriver,
name: 'Tools',
badge: null,
enabled: data.toolsRead
} }
]; ];
let pageTitleSuffix = let pageTitleSuffix = $derived(
(tabs.find((t) => $page.url.pathname === t.path)?.name ?? tabs.find((t) => $page.url.pathname === t.path)?.name ??
$page.url.pathname === `${env.PUBLIC_BASE_PATH}/admin/login`) ($page.url.pathname === `${env.PUBLIC_BASE_PATH}/admin/login` ? 'Login' : null)
? 'Login ' );
: null;
</script> </script>
<svelte:head> <svelte:head>
@ -66,26 +91,38 @@
</svelte:head> </svelte:head>
{#if $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`} {#if $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`}
<div class="h-12 relative bg-base-200 flex justify-center items-center"> <div class="relative bg-base-200 w-full flex justify-center">
<ul class="menu menu-horizontal h-10 p-0 flex items-center bg-base-300 rounded-lg"> <div role="tablist" class="tabs tabs-lifted">
{#each tabs as tab} {#each tabs as tab, i}
{#if tab.enabled} {#if tab.enabled}
<li> {@const Icon = tab.icon}
<a href={tab.path}> <a
<svelte:component this={tab.icon} /> role="tab"
<span class="mr-1" class:underline={$page.url.pathname === tab.path}>{tab.name}</span> 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} {#if tab.badge != null}
<div class="badge">{tab.badge}</div> <div class="badge">{tab.badge}</div>
{/if} {/if}
</a> </span>
</li> </a>
{/if} {/if}
{/each} {/each}
</ul> </div>
<div class="absolute top-0 right-0 flex items-center h-full"> <div class="absolute top-0 right-0 flex items-center h-full">
<ul class="menu menu-vertical"> <ul class="menu menu-vertical">
<li> <li>
<button on:click={(e) => buttonTriggeredRequest(e, logout())}> <button onclick={(e) => buttonTriggeredRequest(e, logout())}>
<ArrowLeftOnRectangle /> <ArrowLeftOnRectangle />
<span>Ausloggen</span> <span>Ausloggen</span>
</button> </button>
@ -93,12 +130,23 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="h-full w-full -mt-12 pt-12 overflow-y-scroll overflow-x-hidden"> <div class="grid">
<slot /> {#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> </div>
{:else} {:else}
<div class="h-full w-full"> <div class="h-full w-full">
<slot /> {@render children()}
</div> </div>
{/if} {/if}

View File

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { Cog6Tooth, Flag, UserGroup, Users } from 'svelte-heros-v2'; import { BookOpen, Cog6Tooth, Flag, UserGroup, Users } from 'svelte-heros-v2';
export let data: PageData; let { data } = $props();
let tabs = [ let tabs = [
{ {
@ -18,6 +17,12 @@
name: 'Reports', name: 'Reports',
enabled: data.reportCount != null 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`, path: `${env.PUBLIC_BASE_PATH}/admin/admin`,
icon: Users, icon: Users,
@ -36,13 +41,14 @@
<div class="flex justify-around items-center h-screen"> <div class="flex justify-around items-center h-screen">
{#each tabs as tab} {#each tabs as tab}
{#if tab.enabled} {#if tab.enabled}
{@const Icon = tab.icon}
<div class="flex flex-col gap-4 justify-center items-center"> <div class="flex flex-col gap-4 justify-center items-center">
<a <a
class="h-64 w-64 border flex justify-center items-center rounded-xl duration-100 hover:bg-base-200" class="h-48 w-48 border flex justify-center items-center rounded-xl duration-100 hover:bg-base-200"
href={tab.path} href={tab.path}
title={tab.name} title={tab.name}
> >
<svelte:component this={tab.icon} width="5rem" height="5rem" /> <Icon />
</a> </a>
<span>{tab.name}</span> <span>{tab.name}</span>
</div> </div>

View File

@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
if (adminCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`); if (adminCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
let admins: (typeof Admin.prototype)[] = []; 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'] } }); admins = await Admin.findAll({ raw: true, attributes: { exclude: ['password'] } });
} }

View File

@ -1,30 +1,33 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types';
import Badges from '$lib/components/Input/Badges.svelte'; import Badges from '$lib/components/Input/Badges.svelte';
import { Check, NoSymbol, PencilSquare, Trash, UserPlus } from 'svelte-heros-v2'; import { Check, NoSymbol, PencilSquare, Trash, UserPlus } from 'svelte-heros-v2';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import { Permissions } from '$lib/permissions'; import { Permissions } from '$lib/permissions';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
import { buttonTriggeredRequest } from '$lib/components/utils'; import { buttonTriggeredRequest } from '$lib/components/utils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
import { adminCount } from '$lib/stores'; import { adminCount } from '$lib/stores';
import { getPopupModalShowFn } from '$lib/context';
let { data } = $props();
let showPopupModal = getPopupModalShowFn();
let admins = $state(data.admins);
let allPermissionBadges = { let allPermissionBadges = {
'Admin Read': Permissions.AdminRead, Admin: Permissions.Admin,
'Admin Write': Permissions.AdminWrite, Users: Permissions.Users,
'User Read': Permissions.UserRead, Reports: Permissions.Reports,
'User Write': Permissions.UserWrite, Feedback: Permissions.Feedback,
'Report Read': Permissions.ReportRead, Settings: Permissions.Settings,
'Report Write': Permissions.ReportWrite, Tools: Permissions.Tools
'Settings Read': Permissions.SettingsRead,
'Settings Write': Permissions.SettingsWrite
}; };
let newAdminUsername: string; let newAdminUsername = $state('');
let newAdminPassword: string; let newAdminPassword = $state('');
let newAdminPermissions: number[]; let newAdminPermissions = $state([]);
async function addAdmin(username: string, password: string, permissions: Permissions) { async function addAdmin(username: string, password: string, permissions: Permissions) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, { const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, {
@ -39,8 +42,11 @@
let res = await response.json(); let res = await response.json();
$adminCount += 1; $adminCount += 1;
res.permissions = new Permissions(res.permissions).asArray(); res.permissions = new Permissions(res.permissions).asArray();
data.admins.push(res); admins.push(res);
data.admins = data.admins;
newAdminUsername = '';
newAdminPassword = '';
newAdminPermissions = [];
} else { } else {
throw new Error(); throw new Error();
} }
@ -82,51 +88,41 @@
await goto(`${env.PUBLIC_BASE_PATH}/`); await goto(`${env.PUBLIC_BASE_PATH}/`);
} else { } else {
$adminCount -= 1; $adminCount -= 1;
data.admins.splice( admins.splice(
data.admins.findIndex((v) => v.id == id), admins.findIndex((v) => v.id == id),
1 1
); );
data.admins = data.admins; admins = admins;
} }
} else { } else {
throw new Error(); throw new Error();
} }
} }
let errorMessage = ''; let permissions = $state(new Permissions(data.permissions));
export let data: PageData;
let permissions = new Permissions(data.permissions);
</script> </script>
<table class="table table-zebra w-full"> <table class="table table-zebra w-full">
<thead> <thead>
<tr> <tr>
<th /> <th></th>
<th>Benutzername</th> <th>Benutzername</th>
<th>Passwort</th> <th>Passwort</th>
<th>Berechtigungen</th> <th>Berechtigungen</th>
<th /> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.admins as admin, i} {#each admins as admin, i}
<tr> <tr>
<td>{i + 1}</td> <td>{i + 1}</td>
<td <td><Input type="text" bind:value={admin.username} disabled={!admin.edit} size="sm" /></td>
><Input
type="text"
bind:value={admin.username}
disabled={!permissions.adminWrite() || !admin.edit}
size="sm"
/></td
>
<td <td
><Input ><Input
type="password" type="password"
bind:value={admin.password} bind:value={admin.password}
placeholder="Neues Passwort..." placeholder="Neues Passwort..."
disabled={!permissions.adminWrite() || !admin.edit} disabled={!admin.edit}
size="sm" size="sm"
/></td /></td
> >
@ -134,63 +130,104 @@
><Badges ><Badges
bind:value={admin.permissions} bind:value={admin.permissions}
available={allPermissionBadges} available={allPermissionBadges}
disabled={!permissions.adminWrite() || !admin.edit} disabled={!admin.edit}
/></td /></td
> >
<td> <td>
<div> <div>
{#if admin.edit} {#if admin.edit}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}> <span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button <button
class="btn btn-sm btn-square" class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()} onclick={async (e) => {
on:click={async (e) => { showPopupModal({
await buttonTriggeredRequest( title: 'Speichern',
e, text: `Sollen die Änderungen für den Admin '${admin.username}' gespeichert werden?`,
updateAdmin( actions: [
admin.id, {
admin.username, text: 'Speichern',
admin.password, action: async () => {
new Permissions(admin.permissions) await buttonTriggeredRequest(
) e,
); updateAdmin(
admin.password = ''; admin.id,
admin.edit = false; admin.username,
admin.password,
new Permissions(admin.permissions)
)
);
admin.password = '';
admin.edit = false;
}
},
{ text: 'Abbrechen' }
]
});
}} }}
> >
<Check size="18" /> <Check size="18" />
</button> </button>
</span> </span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}> <span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button <button
class="btn btn-sm btn-square" class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()} onclick={() => {
on:click={() => { if (
admin.edit = false; admin.username === admin.before.username &&
admin = admin.before; admin.password === admin.before.password &&
JSON.stringify(admin.permissions) === JSON.stringify(admin.before.permissions)
) {
admin.edit = false;
return;
}
showPopupModal({
title: 'Abbrechen',
text: 'Soll die Adminbearbeitung abgebrochen werden?',
actions: [
{
text: 'Abbrechen',
action: () => {
admin.edit = false;
admins[i] = admin.before;
}
},
{ text: 'Schließen' }
]
});
}} }}
> >
<NoSymbol size="18" /> <NoSymbol size="18" />
</button> </button>
</span> </span>
{:else} {:else}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}> <span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button <button
class="btn btn-sm btn-square" class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()} onclick={() => {
on:click={() => { admin.before = $state.snapshot(admin);
admin.edit = true; admin.edit = true;
admin.before = structuredClone(admin);
}} }}
> >
<PencilSquare size="18" /> <PencilSquare size="18" />
</button> </button>
</span> </span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}> <span class="w-min" class:cursor-not-allowed={!permissions.admin()}>
<button <button
class="btn btn-sm btn-square" class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()} onclick={(e) => {
on:click={(e) => buttonTriggeredRequest(e, deleteAdmin(admin.id))} 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' }
]
});
}}
> >
<Trash size="18" /> <Trash size="18" />
</button> </button>
@ -201,34 +238,39 @@
</tr> </tr>
{/each} {/each}
<tr> <tr>
<td>{data.admins.length + 1}</td> <td>{admins.length + 1}</td>
<td><Input type="text" bind:value={newAdminUsername} size="sm" /></td> <td><Input type="text" bind:value={newAdminUsername} size="sm" /></td>
<td><Input type="password" bind:value={newAdminPassword} size="sm" /></td> <td><Input type="password" bind:value={newAdminPassword} size="sm" /></td>
<td <td><Badges bind:value={newAdminPermissions} available={allPermissionBadges} /></td>
><Badges
bind:value={newAdminPermissions}
available={allPermissionBadges}
disabled={!permissions.adminWrite()}
/></td
>
<td> <td>
<span <span class="w-min" class:cursor-not-allowed={!newAdminUsername || !newAdminPassword}>
class="w-min"
class:cursor-not-allowed={!permissions.adminWrite() ||
!newAdminUsername ||
!newAdminPassword}
>
<button <button
class="btn btn-sm btn-square" class="btn btn-sm btn-square"
disabled={!permissions.adminWrite() || !newAdminUsername || !newAdminPassword} disabled={!newAdminUsername || !newAdminPassword}
on:click={async (e) => { onclick={async (e) => {
await buttonTriggeredRequest( showPopupModal({
e, title: 'Admin hinzugügen',
addAdmin(newAdminUsername, newAdminPassword, new Permissions(newAdminPermissions)) text: `Soll der neue Admin ${newAdminUsername} hinzugefügt werden?`,
); actions: [
newAdminUsername = ''; {
newAdminPassword = ''; text: 'Hinzufügen',
newAdminPermissions = []; action: async () => {
await buttonTriggeredRequest(
e,
addAdmin(
newAdminUsername,
newAdminPassword,
new Permissions(newAdminPermissions)
)
);
newAdminUsername = '';
newAdminPassword = '';
newAdminPermissions = [];
}
},
{ text: 'Abbrechen' }
]
});
}} }}
> >
<UserPlus size="18" /> <UserPlus size="18" />
@ -238,7 +280,3 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<ErrorToast show={errorMessage !== ''}>
<span />
</ErrorToast>

View File

@ -5,7 +5,7 @@ import { Admin } from '$lib/server/database';
import { AdminDeleteSchema, AdminEditSchema, AdminListSchema } from './schema'; import { AdminDeleteSchema, AdminEditSchema, AdminListSchema } from './schema';
export const POST = (async ({ request, cookies }) => { export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
} }
@ -29,7 +29,7 @@ export const POST = (async ({ request, cookies }) => {
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => { export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
} }
@ -51,7 +51,7 @@ export const PATCH = (async ({ request, cookies }) => {
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => { export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Admin] }) == null) {
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
} }

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

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

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

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

View File

@ -3,7 +3,8 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { errorMessage } from '$lib/stores'; import { errorMessage } from '$lib/stores';
let passwordValue: string; let password = $state('');
async function login() { async function login() {
// eslint-disable-next-line no-async-promise-executor // eslint-disable-next-line no-async-promise-executor
loginRequest = new Promise(async (resolve) => { loginRequest = new Promise(async (resolve) => {
@ -12,10 +13,10 @@
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0]))) body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
}); });
if (response.ok) { if (response.ok) {
window.location = `${env.PUBLIC_BASE_PATH}/admin`; window.location.href = `${env.PUBLIC_BASE_PATH}/admin`;
resolve(); resolve();
} else if (response.status == 401) { } else if (response.status == 401) {
passwordValue = ''; password = '';
$errorMessage = 'Nutzername oder Passwort falsch'; $errorMessage = 'Nutzername oder Passwort falsch';
resolve(); resolve();
} else { } else {
@ -25,25 +26,29 @@
}); });
} }
let loginRequest: Promise<void> | null = null; let loginRequest: Promise<void> | null = $state(null);
</script> </script>
<div class="card px-14 py-6 shadow-lg"> <div class="card px-14 py-6 shadow-lg">
<h1 class="text-center text-4xl mt-2 mb-4">Craftattack Admin Login</h1> <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="flex flex-col justify-center items-center">
<div class="grid gap-4"> <div class="grid gap-4">
<Input id="username" name="username" type="text" required={true}> <Input id="username" name="username" type="text" required={true}>
<span slot="label">Nutzername</span> {#snippet label()}
<span>Nutzername</span>
{/snippet}
</Input> </Input>
<Input <Input id="password" name="password" type="password" required={true} bind:value={password}>
id="password" {#snippet label()}
name="password" <span>Passwort</span>
type="password" {/snippet}
required={true}
bind:value={passwordValue}
>
<span slot="label">Passwort</span>
</Input> </Input>
</div> </div>
</div> </div>
@ -56,22 +61,7 @@
{#await loginRequest} {#await loginRequest}
<span <span
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring" class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
/> ></span>
{: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>
{/await} {/await}
{/if} {/if}
{/key} {/key}

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types';
import type { Report } from '$lib/server/database'; import type { Report } from '$lib/server/database';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
@ -16,30 +15,32 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Search from '$lib/components/Input/Search.svelte'; import Search from '$lib/components/Input/Search.svelte';
import { usernameSuggestions } from '$lib/utils'; 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 showPopupModal = getPopupModalShowFn();
let currentPageTotal = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let currentPageReportsRequest: Promise<any> = Promise.resolve();
let reportsPerPage = 50;
let reportPage = 0;
let reportFilter = { draft: false, status: null, reporter: null, reported: null };
let activeReport: typeof Report.prototype.dataValues | null = null;
async function fetchPageReports( let reports: (typeof Report.prototype.dataValues)[] = $state([]);
page: number, let reportsPerRequest = 25;
filter: typeof reportFilter | { hash: string } let reportFilter = $state({ draft: false, status: null, reporter: null, reported: null });
): Promise<{ reports: typeof currentPageReports; count: number }> { 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 }; if (!browser) return { reports: [], count: 0 };
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, { const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...filter, ...reportFilter,
limit: reportsPerPage, limit: extendedFilter?.limit ?? reportsPerRequest,
from: reportsPerPage * page from: extendedFilter?.from ?? reports.length,
hash: extendedFilter?.hash
}) })
}); });
@ -51,20 +52,15 @@
return await response.json(); return await response.json();
} }
$: currentPageReportsRequest = fetchPageReports(reportPage, reportFilter).then((r) => {
currentPageReports = r.reports;
currentPageTotal = r.count;
});
async function openHashReport() { async function openHashReport() {
if (!window.location.hash) return; if (!window.location.hash) return;
const requestedHash = window.location.hash.substring(1); const requestedHash = window.location.hash.substring(1);
let report = currentPageReports.find((r) => r.url_hash === requestedHash); let report = reports.find((r) => r.url_hash === requestedHash);
if (!report) { if (!report) {
const hashReport = (await fetchPageReports(0, { hash: requestedHash })).reports[0]; const hashReport = (await fetchReports({ hash: requestedHash })).reports[0];
if (hashReport) { if (hashReport) {
currentPageReports = [hashReport, ...currentPageReports]; reports = [hashReport, ...reports];
report = hashReport; report = hashReport;
} else { } else {
await goto(window.location.href.split('#')[0], { replaceState: true }); await goto(window.location.href.split('#')[0], { replaceState: true });
@ -75,10 +71,8 @@
activeReport = report; activeReport = report;
activeReport.originalStatus = report; activeReport.originalStatus = report;
} }
onMount(async () => {
await currentPageReportsRequest;
await openHashReport();
onMount(async () => {
if (browser) window.addEventListener('hashchange', openHashReport); if (browser) window.addEventListener('hashchange', openHashReport);
}); });
onDestroy(() => { onDestroy(() => {
@ -100,13 +94,26 @@
}); });
} }
let saveActiveReportChangesModal: HTMLDialogElement;
let newReportModal: HTMLDialogElement; let newReportModal: HTMLDialogElement;
</script> </script>
<div class="h-full flex flex-row"> <div class="h-full flex flex-row">
<div class="w-full flex flex-col overflow-scroll"> <div class="w-full flex flex-col overflow-scroll">
<HeaderBar bind:reportFilter /> <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" /> <hr class="divider my-1 mx-8 border-none" />
<table class="table table-fixed h-fit"> <table class="table table-fixed h-fit">
<colgroup> <colgroup>
@ -118,25 +125,28 @@
<col style="width: 15%" /> <col style="width: 15%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>Grund</th> <th>Grund</th>
<th>Ersteller</th> <th>Ersteller</th>
<th>Reporteter User</th> <th>Reporteter User</th>
<th>Datum</th> <th>Datum</th>
<th>Bearbeitungsstatus</th> <th>Bearbeitungsstatus</th>
<th>Reportstatus</th> <th>Reportstatus</th>
</tr> </tr>
</thead> </thead>
<tbody> <PaginationTableBody
{#each currentPageReports as report} onUpdate={async () =>
await fetchReports().then((res) => (reports = [...reports, ...res.reports]))}
>
{#each reports as report}
<tr <tr
class="hover [&>*]:text-sm cursor-pointer" class="hover [&>*]:text-sm cursor-pointer"
class:bg-base-200={activeReport?.url_hash === report.url_hash} class:bg-base-200={activeReport?.url_hash === report.url_hash}
on:click={() => { onclick={() => {
goto(`${window.location.href.split('#')[0]}#${report.url_hash}`, { goto(`${window.location.href.split('#')[0]}#${report.url_hash}`, {
replaceState: true replaceState: true
}); });
activeReport = report; activeReport = $state.snapshot(report);
activeReport.originalStatus = report.status; activeReport.originalStatus = report.status;
}} }}
> >
@ -146,7 +156,11 @@
<button <button
class="pl-1" class="pl-1"
title="Nach Ersteller filtern" title="Nach Ersteller filtern"
on:click|stopPropagation={() => (reportFilter.reporter = report.reporter.username)} onclick={(e) => {
e.stopPropagation();
reportFilter.reporter = report.reporter.username;
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
}}
> >
<MagnifyingGlass size="14" /> <MagnifyingGlass size="14" />
</button> </button>
@ -157,21 +171,24 @@
<button <button
class="pl-1" class="pl-1"
title="Nach Reportetem Spieler filtern" title="Nach Reportetem Spieler filtern"
on:click|stopPropagation={() => onclick={(e) => {
(reportFilter.reported = report.reported.username)} e.stopPropagation();
reportFilter.reported = report.reported.username;
fetchReports({ from: 0 }).then((r) => (reports = r.reports));
}}
> >
<MagnifyingGlass size="14" /> <MagnifyingGlass size="14" />
</button> </button>
{/if} {/if}
</td> </td>
<td <td
>{new Intl.DateTimeFormat('de-DE', { >{new Intl.DateTimeFormat('de-DE', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}).format(new Date(report.createdAt))} Uhr</td }).format(new Date(report.createdAt))} Uhr</td
> >
<td> <td>
{report.status === 'none' {report.status === 'none'
@ -185,32 +202,8 @@
<td>{report.draft ? 'Entwurf' : 'Erstellt'}</td> <td>{report.draft ? 'Entwurf' : 'Erstellt'}</td>
</tr> </tr>
{/each} {/each}
<tr> </PaginationTableBody>
<td colspan="100">
<div class="flex justify-center items-center">
<button class="btn btn-sm" on:click={() => newReportModal.show()}>
<Plus />
<span>Neuer Report</span>
</button>
</div>
</td>
</tr>
</tbody>
</table> </table>
<div class="flex justify-center items-center mb-2 mt-4 w-full">
<div class="join">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each Array(currentPageReports.length === reportsPerPage || reportPage > 0 ? Math.ceil(currentPageTotal / reportsPerPage) || 1 : 1) as _, i}
<button
class="join-item btn"
class:btn-active={i === reportPage}
on:click={() => {
reportPage = i;
}}>{i + 1}</button
>
{/each}
</div>
</div>
</div> </div>
{#if activeReport} {#if activeReport}
<div <div
@ -219,18 +212,18 @@
> >
<div class="absolute right-2 top-2 flex justify-center"> <div class="absolute right-2 top-2 flex justify-center">
<form class="dropdown dropdown-end"> <form class="dropdown dropdown-end">
<!-- svelte-ignore a11y-no-noninteractive-tabindex a11y-label-has-associated-control --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center"> <label tabindex="0" class="btn btn-sm btn-circle btn-ghost text-center">
<Share size="1rem" /> <Share size="1rem" />
</label> </label>
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul <ul
tabindex="0" tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-max"
> >
<li> <li>
<button <button
on:click={() => { onclick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeReport.url_hash}` `${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/admin/reports#${activeReport.url_hash}`
); );
@ -239,7 +232,7 @@
Internen Link kopieren Internen Link kopieren
</button> </button>
<button <button
on:click={() => onclick={() =>
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeReport.url_hash}` `${window.location.protocol}//${window.location.host}${env.PUBLIC_BASE_PATH}/report/${activeReport.url_hash}`
)}>Öffentlichen Link kopieren</button )}>Öffentlichen Link kopieren</button
@ -249,7 +242,7 @@
</form> </form>
<button <button
class="btn btn-sm btn-circle btn-ghost" class="btn btn-sm btn-circle btn-ghost"
on:click={() => { onclick={() => {
activeReport = null; activeReport = null;
goto(window.location.href.split('#')[0], { replaceState: true }); goto(window.location.href.split('#')[0], { replaceState: true });
}}></button }}></button
@ -258,7 +251,9 @@
<h3 class="font-roboto font-semibold text-2xl mb-2">Report</h3> <h3 class="font-roboto font-semibold text-2xl mb-2">Report</h3>
<div class="w-full"> <div class="w-full">
<Input readonly={true} size="sm" value={activeReport.reporter.username} pickyWidth={false}> <Input readonly={true} size="sm" value={activeReport.reporter.username} pickyWidth={false}>
<span slot="label">Reporter</span> {#snippet label()}
<span>Reporter</span>
{/snippet}
</Input> </Input>
<Search <Search
size="sm" size="sm"
@ -267,18 +262,18 @@
searchSuggestionFunc={usernameSuggestions} searchSuggestionFunc={usernameSuggestions}
invalidMessage="Es können nur registrierte Spieler reportet werden" invalidMessage="Es können nur registrierte Spieler reportet werden"
label="Reporteter User" label="Reporteter User"
inputValue={activeReport.reported?.username || ''} value={activeReport.reported?.username || ''}
on:submit={(e) => onsubmit={(e) =>
(activeReport.reported = { (activeReport.reported = {
...activeReport.reported, ...activeReport.reported,
username: e.detail.input, username: e.input,
uuid: e.detail.value uuid: e.value
})} })}
/> />
<Textarea readonly={true} rows={1} label="Report Grund" value={activeReport.subject} /> <Textarea readonly={true} rows={1} label="Report Grund" value={activeReport.subject} />
<Textarea readonly={true} rows={4} label="Report Details" value={activeReport.body} /> <Textarea readonly={true} rows={4} label="Report Details" value={activeReport.body} />
</div> </div>
<div class="divider mx-4" /> <div class="divider mx-4"></div>
<div> <div>
<div <div
class="w-full" class="w-full"
@ -310,7 +305,7 @@
<option <option
value="none" value="none"
disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement} disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement}
>Unbearbeitet</option >Unbearbeitet</option
> >
<option value="review">In Bearbeitung</option> <option value="review">In Bearbeitung</option>
<option value="reviewed">Bearbeitet</option> <option value="reviewed">Bearbeitet</option>
@ -334,55 +329,57 @@
<Input <Input
type="submit" type="submit"
value="Speichern" value="Speichern"
on:click={() => saveActiveReportChangesModal.show()} onclick={() => {
showPopupModal({
title: 'Änderungen Speichern?',
actions: [
{
text: 'Speichern',
action: async () => {
await updateActiveReport();
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' &&
activeReport.status !== 'reviewed'
) {
$reportCount += 1;
}
}
},
{ text: 'Abbrechen' }
]
});
}}
/> />
</div> </div>
</div> </div>
{/if} {/if}
</div> </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 () => {
await updateActiveReport();
if (activeReport.reported?.username) {
if (activeReport.reported?.id === undefined) {
activeReport.reported.id = -1;
}
} else {
activeReport.reported = undefined;
}
currentPageReports = [...currentPageReports];
if (activeReport.originalStatus !== 'reviewed' && activeReport.status === 'reviewed') {
$reportCount -= 1;
} else if (
activeReport.originalStatus === 'reviewed' &&
activeReport.status !== 'reviewed'
) {
$reportCount += 1;
}
}}
/>
<Input type="submit" value="Abbrechen" />
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
</dialog>
<dialog class="modal" bind:this={newReportModal}> <dialog class="modal" bind:this={newReportModal}>
<NewReportModal <NewReportModal
on:submit={(e) => { onSubmit={(e) => {
if (!e.detail.draft) $reportCount += 1; if (!e.draft) $reportCount += 1;
currentPageReports = [e.detail, ...currentPageReports]; reports = [e, ...reports];
activeReport = currentPageReports[0]; activeReport = $state.snapshot(reports[0]);
newReportModal.close(); newReportModal.close();
}} }}
/> />

View File

@ -10,7 +10,7 @@ import { webhookUserReported } from '$lib/server/webhook';
import { ReportAddSchema, ReportEditSchema, ReportListSchema } from './schema'; import { ReportAddSchema, ReportEditSchema, ReportListSchema } from './schema';
export const POST = (async ({ request, cookies }) => { export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.ReportRead] }) == null) { if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });
@ -18,7 +18,6 @@ export const POST = (async ({ request, cookies }) => {
const parseResult = await ReportListSchema.safeParseAsync(await request.json()); const parseResult = await ReportListSchema.safeParseAsync(await request.json());
if (!parseResult.success) { if (!parseResult.success) {
console.log(parseResult.error);
return new Response(null, { return new Response(null, {
status: 400 status: 400
}); });
@ -51,12 +50,7 @@ export const POST = (async ({ request, cookies }) => {
let reports = await Report.findAll({ let reports = await Report.findAll({
where: reportFindOptions, where: reportFindOptions,
include: [ include: [{ all: true }],
{ model: User, as: 'reporter' },
{ model: User, as: 'reported' },
{ model: Admin, as: 'auditor' },
{ model: StrikeReason, as: 'strike_reason' }
],
order: [['created_at', 'DESC']], order: [['created_at', 'DESC']],
offset: data.from || 0, offset: data.from || 0,
limit: data.limit || 100 limit: data.limit || 100
@ -89,7 +83,7 @@ export const POST = (async ({ request, cookies }) => {
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => { export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.ReportWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });
@ -103,67 +97,68 @@ export const PATCH = (async ({ request, cookies }) => {
} }
const data = parseResult.data; const data = parseResult.data;
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 admin = await Admin.findOne({ where: { id: data.auditor } });
const reported = data.reported const reported = data.reported ? await User.findOne({ where: { uuid: data.reported } }) : null;
? await User.findOne({ where: { uuid: data.reported } }) if (report === null || (admin === null && data.auditor != -1))
: undefined;
if (report === null || (admin === null && data.auditor != -1) || reported === null)
return new Response(null, { status: 400 }); return new Response(null, { status: 400 });
const webhookTriggerUsers: string[] = []; const webhookTriggerUsers: string[] = [];
if (report.reported_id != reported?.id) {
const oldReportUser = await User.findByPk(report.reported_id); // check if strike reason has changed and return 400 if it doesn't exist
if (oldReportUser) webhookTriggerUsers.push(oldReportUser.uuid); if (
if (reported) webhookTriggerUsers.push(reported.uuid); (report.strike_reason?.id ?? -1) != data.strike_reason &&
} else if ( data.strike_reason != null &&
reported && data.strike_reason != -1
report.reported_id != null &&
report.strike_reason_id != data.strike_reason
) { ) {
webhookTriggerUsers.push(reported.uuid); 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!);
}
} }
report.reported_id = reported?.id ?? null;
if (data.notice != null) report.notice = data.notice; if (data.notice != null) report.notice = data.notice;
if (data.statement != null) report.statement = data.statement; if (data.statement != null) report.statement = data.statement;
if (data.status != null) report.status = data.status; if (data.status != null) report.status = data.status;
if (data.strike_reason != null) { if (data.reported != null && reported) report.reported_id = reported.id;
if (data.status !== 'reviewed') { if (data.strike_reason != null)
if (data.strike_reason == -1) { report.strike_reason_id = data.strike_reason == -1 ? null : data.strike_reason;
report.strike_reason_id = null; if (data.strike_reason != null)
} else { report.striked_at = data.strike_reason == -1 ? null : new Date(Date.now());
const strike_reason = await StrikeReason.findByPk(data.strike_reason);
if (strike_reason == null) return new Response(null, { status: 400 });
report.strike_reason_id = strike_reason.id;
}
} else if (data.strike_reason == -1 && report.strike_reason_id != null) {
report.strike_reason_id = null;
report.striked_at = null;
} else if (data.strike_reason != -1 && data.strike_reason != report.strike_reason_id) {
if (!report.reported_id) return new Response(null, { status: 400 });
const strike_reason = await StrikeReason.findByPk(data.strike_reason);
if (strike_reason == null) return new Response(null, { status: 400 });
report.strike_reason_id = strike_reason.id;
report.striked_at = new Date(Date.now());
}
}
if (admin != null) report.auditor_id = admin.id; if (admin != null) report.auditor_id = admin.id;
await report.save(); await report.save();
if (webhookTriggerUsers.length > 0 && data.status == 'reviewed' && env.REPORTED_WEBHOOK) { for (const webhookTriggerUser of webhookTriggerUsers) {
for (const webhookTriggerUser of webhookTriggerUsers) { // no `await` to avoid blocking
// no `await` to avoid blocking webhookUserReported(env.REPORTED_WEBHOOK, webhookTriggerUser).catch((e) =>
webhookUserReported(env.REPORTED_WEBHOOK, webhookTriggerUser); console.error(`failed to send reported webhook: ${e}`)
} );
} }
return new Response(); return new Response();
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const PUT = (async ({ request, cookies }) => { export const PUT = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.ReportWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Reports] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });

View File

@ -2,28 +2,38 @@
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
export let reportFilter = { let {
reporter: null, reportFilter = $bindable({
reported: null, reporter: undefined,
status: null, reported: undefined,
draft: false status: undefined,
}; draft: false
}),
onUpdate
}: {
reportFilter: { reporter?: string; reported?: string; status?: string; draft: false };
onUpdate: () => void;
} = $props();
</script> </script>
<form class="flex flex-row justify-center space-x-4 mx-4 my-2"> <form class="flex flex-row justify-center space-x-4 mx-4 my-2">
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}> <Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter} oninput={onUpdate}>
<span slot="label">Report Ersteller</span> {#snippet label()}
<span>Report Ersteller</span>
{/snippet}
</Input> </Input>
<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}> <Input size="sm" placeholder="Alle" bind:value={reportFilter.reported} oninput={onUpdate}>
<span slot="label">Reportete Spieler</span> {#snippet label()}
<span>Reporteter Spieler</span>
{/snippet}
</Input> </Input>
<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}> <Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status} onChange={onUpdate}>
<option value="none">Unbearbeitet</option> <option value="none">Unbearbeitet</option>
<option value="review">In Bearbeitung</option> <option value="review">In Bearbeitung</option>
<option value={null}>Unbearbeitet & In Bearbeitung</option> <option value={null}>Unbearbeitet & In Bearbeitung</option>
<option value="reviewed">Bearbeitet</option> <option value="reviewed">Bearbeitet</option>
</Select> </Select>
<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}> <Select label="Reportstatus" size="sm" bind:value={reportFilter.draft} onChange={onUpdate}>
<option value={false}>Erstellt</option> <option value={false}>Erstellt</option>
<option value={true}>Entwurf</option> <option value={true}>Entwurf</option>
</Select> </Select>

View File

@ -3,15 +3,18 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import Textarea from '$lib/components/Input/Textarea.svelte'; import Textarea from '$lib/components/Input/Textarea.svelte';
import Search from '$lib/components/Input/Search.svelte'; import Search from '$lib/components/Input/Search.svelte';
import { createEventDispatcher } from 'svelte';
import { usernameSuggestions } from '$lib/utils'; import { usernameSuggestions } from '$lib/utils';
import type { Report } from '$lib/server/database';
import { getPopupModalShowFn } from '$lib/context';
const dispatch = createEventDispatcher(); let { onSubmit }: { onSubmit: (data: typeof Report.prototype.dataValues) => void } = $props();
let reporter: string; let showPopupModal = getPopupModalShowFn();
let reported: string;
let reason = ''; let reporter: string | undefined = $state();
let body = ''; let reported: string | undefined = $state();
let reason = $state('');
let body = $state('');
async function newReport() { async function newReport() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, { const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
@ -23,21 +26,23 @@
body: body || null body: body || null
}) })
}); });
if (response.ok) dispatch('submit', await response.json()); if (response.ok) onSubmit(await response.json());
} }
let globalCloseForm: HTMLFormElement; let globalCloseForm: HTMLFormElement;
let reportForm: HTMLFormElement; let reportForm: HTMLFormElement;
let confirmDialog: HTMLDialogElement;
</script> </script>
<form method="dialog" class="modal-box" bind:this={reportForm}> <form method="dialog" class="modal-box" bind:this={reportForm}>
<button <button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
on:click|preventDefault={() => globalCloseForm.submit()}>✕</button onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}>✕</button
> >
<h3 class="font-roboto text-xl">Neuer Report</h3> <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 class="space-y-2 mt-2 px-1 max-h-[70vh] overflow-y-scroll">
<div> <div>
<Search <Search
@ -59,9 +64,11 @@
bind:value={reported} bind:value={reported}
/> />
</div> </div>
<div class="divider mx-4 pt-3" /> <div class="divider mx-4 pt-3"></div>
<Input type="text" bind:value={reason} required={true} pickyWidth={false}> <Input type="text" bind:value={reason} required={true} pickyWidth={false}>
<span slot="label">Report Grund</span> {#snippet label()}
<span>Report Grund</span>
{/snippet}
</Input> </Input>
<div> <div>
<Textarea rows={4} label="Details über den Report Grund" bind:value={body} /> <Textarea rows={4} label="Details über den Report Grund" bind:value={body} />
@ -71,18 +78,33 @@
<Input <Input
type="submit" type="submit"
value="Erstellen" value="Erstellen"
on:click={(e) => { onclick={(e) => {
if (reportForm.checkValidity()) { if (reportForm.checkValidity()) {
e.detail.preventDefault(); e.preventDefault();
confirmDialog.show(); 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 <Input
type="submit" type="submit"
value="Abbrechen" value="Abbrechen"
on:click={(e) => { onclick={(e) => {
e.detail.preventDefault(); e.preventDefault();
globalCloseForm.submit(); globalCloseForm.submit();
}} }}
/> />
@ -91,35 +113,3 @@
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}> <form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
<button>close</button> <button>close</button>
</form> </form>
<dialog class="modal" bind:this={confirmDialog}>
<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 mb-2">Report Erstellen?</h3>
{#if body}
<p>
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
</p>
{:else}
<p>
Der Report wird als Entwurf gespeichert und kann nach dem Erstellen über den Report-Link
bearbeitet werden
</p>
{/if}
<div class="flex flex-row space-x-2 mt-6">
<Input
type="submit"
value="Erstellen"
on:click={async () => {
await newReport();
globalCloseForm.submit();
}}
/>
<Input type="submit" value="Abbrechen" />
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
</dialog>

View File

@ -6,7 +6,7 @@ import { env } from '$env/dynamic/public';
import { Settings } from '$lib/server/database'; import { Settings } from '$lib/server/database';
export const load: PageServerLoad = async ({ parent, cookies }) => { export const load: PageServerLoad = async ({ parent, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.SettingsRead] }) == null) { if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`); throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
} }
@ -16,15 +16,12 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
(prev, curr) => { (prev, curr) => {
return { ...prev, [curr.key]: curr.value }; return { ...prev, [curr.key]: curr.value };
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as { [key: string]: any } {} as { [key: string]: any }
); );
return { return {
settings: { settings: {
global: { global: {},
paypal_link: settings['global.paypal_link'] ?? ''
},
register: { register: {
enabled: settings['register.enabled'] ?? true, enabled: settings['register.enabled'] ?? true,
disabled_title: settings['register.disabled_title'] ?? 'Anmeldung geschlossen', disabled_title: settings['register.disabled_title'] ?? 'Anmeldung geschlossen',

View File

@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
export let data: PageData;
let settings = structuredClone(data.settings); let { data } = $props();
let settings = $state($state.snapshot(data.settings));
async function change() { async function change() {
await fetch(`${env.PUBLIC_BASE_PATH}/admin/settings`, { await fetch(`${env.PUBLIC_BASE_PATH}/admin/settings`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
global: { global: {},
paypal_link: returnIfNoDup(settings.global.paypal_link, data.settings.global.paypal_link)
},
register: { register: {
enabled: returnIfNoDup(settings.register.enabled, data.settings.register.enabled), enabled: returnIfNoDup(settings.register.enabled, data.settings.register.enabled),
disabled_title: returnIfNoDup( disabled_title: returnIfNoDup(
@ -25,7 +25,7 @@
} as PageData['settings']) } as PageData['settings'])
}); });
data.settings = settings; data.settings = settings;
settings = structuredClone(data.settings); settings = $state.snapshot(data.settings);
} }
function returnIfNoDup<T>(value: T, original: T): T | undefined { function returnIfNoDup<T>(value: T, original: T): T | undefined {
@ -35,13 +35,13 @@
<div class="h-full flex flex-col items-center justify-between"> <div class="h-full flex flex-col items-center justify-between">
<div class="grid grid-cols-3 w-full [&>*]:mx-8"> <div class="grid grid-cols-3 w-full [&>*]:mx-8">
<div> <!--div>
<div class="divider">Global</div> <div class="divider">Global</div>
<label class="label"> <label class="label">
<span class="label-text">PayPal-Spendenlink</span> <span class="label-text">PayPal-Spendenlink</span>
<input type="text" class="input input-bordered" bind:value={settings.global.paypal_link} /> <input type="text" class="input input-bordered" bind:value={settings.global.paypal_link} />
</label> </label>
</div> </div-->
<div> <div>
<div class="divider">Anmeldung</div> <div class="divider">Anmeldung</div>
<label class="label cursor-pointer"> <label class="label cursor-pointer">
@ -70,7 +70,7 @@
<button <button
class="btn btn-success mt-auto" class="btn btn-success mt-auto"
class:btn-disabled={JSON.stringify(data.settings) === JSON.stringify(settings)} class:btn-disabled={JSON.stringify(data.settings) === JSON.stringify(settings)}
on:click={change}>Speichern</button onclick={change}>Speichern</button
> >
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { Permissions } from '$lib/permissions';
import { Settings } from '$lib/server/database'; import { Settings } from '$lib/server/database';
export const POST = (async ({ request, cookies }) => { export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.SettingsWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Settings] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });

View File

@ -0,0 +1,3 @@
<div class="flex justify-center">
<slot />
</div>

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

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

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

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

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FindUuidSchema = z.object({
username: z.string(),
edition: z.enum(['java', 'bedrock'])
});

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

View File

@ -11,6 +11,6 @@ export const load: PageServerLoad = async ({ parent, cookies }) => {
return { return {
count: count:
getSession(cookies, { permissions: [Permissions.UserRead] }) != null ? await User.count() : 0 getSession(cookies, { permissions: [Permissions.Users] }) != null ? await User.count() : 0
}; };
}; };

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types';
import { Check, NoSymbol, PencilSquare, Plus, Trash } from 'svelte-heros-v2'; import { Check, NoSymbol, PencilSquare, Plus, Trash } from 'svelte-heros-v2';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
@ -11,74 +10,43 @@
import SortableTr from '$lib/components/Table/SortableTr.svelte'; import SortableTr from '$lib/components/Table/SortableTr.svelte';
import SortableTh from '$lib/components/Table/SortableTh.svelte'; import SortableTh from '$lib/components/Table/SortableTh.svelte';
import NewUserModal from './NewUserModal.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 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 usersPerPage = 200;
let userPage = 0;
let userFilter = { name: null, playertype: null };
let userTableContainerElement: HTMLDivElement; let userTableContainerElement: HTMLDivElement;
let newUserModal: HTMLDialogElement; let newUserModal: HTMLDialogElement;
function fetchPageUsers(page: number) { async function fetchUsers(extendedFilter?: {
if (!browser) return; limit?: number;
from?: number;
}): Promise<typeof users> {
if (!browser) return [];
if (userTableContainerElement) userTableContainerElement.scrollTop = 0; if (userTableContainerElement) userTableContainerElement.scrollTop = 0;
// eslint-disable-next-line no-async-promise-executor const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
currentPageUsersRequest = new Promise(async (resolve, reject) => { method: 'POST',
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, { body: JSON.stringify({
method: 'POST', ...userFilter,
body: JSON.stringify({ ...userFilter, limit: usersPerPage, from: usersPerPage * page }) limit: extendedFilter?.limit ?? usersPerRequest,
}); from: extendedFilter?.from ?? users.length
if (response.ok) { })
currentPageUsers = await response.json();
resolve();
} else {
reject(Error());
}
}); });
return await response.json();
} }
$: fetchPageUsers(userPage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
function fetchFilterPageUsers(_: any) {
userPage == 0 ? fetchPageUsers(0) : (userPage = 0);
}
$: fetchFilterPageUsers(userFilter);
let sortKey: string | null = null;
let sortAsc = false;
$: if (sortKey != null)
currentPageUsers = currentPageUsers.sort((entryA, entryB) => {
const multiplyValue = sortAsc ? -1 : 1;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const a = entryA[sortKey];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const b = entryB[sortKey];
switch (typeof a) {
case 'number':
return (a - b) * multiplyValue;
case 'string':
return a.localeCompare(b) * multiplyValue;
default:
return (a - b) * multiplyValue;
}
});
async function updateUser(user: typeof User.prototype.dataValues) { 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', method: 'PATCH',
body: JSON.stringify(user) body: JSON.stringify(user)
}); });
if (!response.ok) {
throw new Error();
}
} }
async function deleteUser(id: number) { async function deleteUser(id: number) {
@ -89,161 +57,201 @@
}) })
}); });
if (response.ok) { if (response.ok) {
currentPageUsers.splice( users.splice(
currentPageUsers.findIndex((v) => v.id == id), users.findIndex((v) => v.id == id),
1 1
); );
currentPageUsers = currentPageUsers; users = users;
} else {
throw new Error();
} }
} }
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> </script>
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<HeaderBar bind:userFilter /> <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" /> <hr class="divider my-1 mx-8 border-none" />
<div class="h-full overflow-scroll" bind:this={userTableContainerElement}> <div class="h-full overflow-scroll" bind:this={userTableContainerElement}>
<table class="table table-auto"> <table class="table table-auto">
<thead> <thead>
<!-- prettier-ignore --> <!-- prettier-ignore -->
<SortableTr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0"> <SortableTr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
<th /> <th></th>
<SortableTh on:sort={(e) => { sortKey = 'firstname'; sortAsc = e.detail.asc }}>Vorname</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'firstname', asc: e.asc}}}>Vorname</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'lastname'; sortAsc = e.detail.asc }}>Nachname</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'lastname', asc: e.asc}}}>Nachname</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'birthday'; sortAsc = e.detail.asc }}>Geburtstag</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'birthday', asc: e.asc}}}>Geburtstag</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'telephone'; sortAsc = e.detail.asc }}>Telefon</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'telephone', asc: e.asc}}}>Telefon</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'username'; sortAsc = e.detail.asc }}>Username</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'username', asc: e.asc}}}>Username</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'playertype'; sortAsc = e.detail.asc }}>Minecraft Edition</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'playertype', asc: e.asc}}}>Minecraft Edition</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'password'; sortAsc = e.detail.asc }}>Passwort</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'password', asc: e.asc}}}>Passwort</SortableTh>
<SortableTh on:sort={(e) => { sortKey = 'uuid'; sortAsc = e.detail.asc }}>UUID</SortableTh> <SortableTh onSort={(e) => userFilter = {...userFilter, sort: {key: 'uuid', asc: e.asc}}}>UUID</SortableTh>
<th /> <th></th>
</SortableTr> </SortableTr>
</thead> </thead>
<tbody> <PaginationTableBody
{#key currentPageUsersRequest} onUpdate={async () => {
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars --> await fetchUsers().then((u) => (users = [...users, ...u]));
{#await currentPageUsersRequest then _} }}
{#each currentPageUsers as user, i} >
<tr> {#each users as user, i}
<td>{i + 1 + userPage * usersPerPage}</td>
<td>
<Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
</td>
<td>
<Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
</td>
<td>
<Input
type="date"
value={new Date(user.birthday).toISOString().split('T')[0]}
on:input={(e) => (user.birthday = e.detail.target.valueAsDate.toISOString())}
disabled={!user.edit}
size="sm"
/>
</td>
<td>
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
</td>
<td>
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
</td>
<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="noauth">Java noauth</option>
</Select>
</td>
<td>
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
</td>
<td>
<Input
id="uuid"
type="text"
bind:value={user.uuid}
disabled={!user.edit}
size="sm"
/>
</td>
<td>
<div class="flex gap-1">
{#if user.edit}
<button
class="btn btn-sm btn-square"
on:click={async (e) => {
await buttonTriggeredRequest(e, updateUser(user));
user.edit = false;
}}
>
<Check size="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={() => {
user.edit = false;
user = user.before;
}}
>
<NoSymbol size="18" />
</button>
{:else}
<button
class="btn btn-sm btn-square"
on:click={() => {
user.before = structuredClone(user);
user.edit = true;
}}
>
<PencilSquare size="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={(e) => buttonTriggeredRequest(e, deleteUser(user.id))}
>
<Trash size="18" />
</button>
{/if}
</div>
</td>
</tr>
{/each}
{/await}
<tr> <tr>
<td colspan="100"> <td>{i + 1}</td>
<div class="flex justify-center items-center"> <td>
<button class="btn btn-sm" on:click={() => newUserModal.show()}> <Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
<Plus /> </td>
<span>Neuer Spieler</span> <td>
</button> <Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
</td>
<td>
<Input
type="date"
value={new Date(user.birthday).toISOString().split('T')[0]}
oninput={(e) => (user.birthday = e.currentTarget.valueAsDate.toISOString())}
disabled={!user.edit}
size="sm"
/>
</td>
<td>
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
</td>
<td>
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
</td>
<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="noauth">Java noauth</option>
</Select>
</td>
<td>
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
</td>
<td>
<Input id="uuid" type="text" bind:value={user.uuid} disabled={!user.edit} size="sm" />
</td>
<td>
<div class="flex gap-1">
{#if user.edit}
<button
class="btn btn-sm btn-square"
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' }
]
});
}}
>
<Check size="18" />
</button>
<button
class="btn btn-sm btn-square"
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;
return;
}
showPopupModal({
title: 'Abbrechen',
text: 'Soll die Nutzerbearbeitung abgebrochen werden?',
actions: [
{
text: 'Abbrechen',
action: () => {
user.edit = false;
users[i] = user.before;
}
},
{ text: 'Schließen' }
]
});
}}
>
<NoSymbol size="18" />
</button>
{:else}
<button
class="btn btn-sm btn-square"
onclick={() => {
user.before = $state.snapshot(user);
user.edit = true;
}}
>
<PencilSquare size="18" />
</button>
<button
class="btn btn-sm btn-square"
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' }
]
});
}}
>
<Trash size="18" />
</button>
{/if}
</div> </div>
</td> </td>
</tr> </tr>
{/key}
</tbody>
</table>
<div class="flex justify-center items-center mb-2 mt-4 w-full">
<div class="join">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each Array(currentPageUsers.length === usersPerPage || userPage > 0 ? Math.ceil(data.count / usersPerPage) || 1 : 1) as _, i}
<button
class="join-item btn"
class:btn-active={i === userPage}
on:click={() => {
userPage = i;
}}>{i + 1}</button
>
{/each} {/each}
</div> </PaginationTableBody>
</div> </table>
</div> </div>
</div> </div>
<dialog class="modal" bind:this={newUserModal}> <dialog class="modal" bind:this={newUserModal}>
<NewUserModal <NewUserModal
on:submit={(e) => { onSubmit={(e) => {
currentPageUsers = [...currentPageUsers, e.detail]; users = [...users, e];
newUserModal.close(); newUserModal.close();
}} }}
/> />

View File

@ -3,11 +3,17 @@ import { Permissions } from '$lib/permissions';
import { error, type RequestHandler } from '@sveltejs/kit'; import { error, type RequestHandler } from '@sveltejs/kit';
import { User } from '$lib/server/database'; import { User } from '$lib/server/database';
import { type Attributes, Op } from 'sequelize'; import { type Attributes, Op } from 'sequelize';
import { ApiError, getJavaUuid, getNoAuthUuid, UserNotFoundError } from '$lib/server/minecraft'; import {
ApiError,
getJavaUuid,
getNoAuthUuid,
RateLimitError,
UserNotFoundError
} from '$lib/server/minecraft';
import { UserAddSchema, UserDeleteSchema, UserEditSchema, UserListSchema } from './schema'; import { UserAddSchema, UserDeleteSchema, UserEditSchema, UserListSchema } from './schema';
export const POST = (async ({ request, cookies }) => { export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) { if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });
@ -43,14 +49,15 @@ export const POST = (async ({ request, cookies }) => {
where: usersFindOptions, where: usersFindOptions,
attributes: data.slim ? ['username', 'uuid'] : undefined, attributes: data.slim ? ['username', 'uuid'] : undefined,
offset: data.from || 0, offset: data.from || 0,
limit: data.limit || 100 limit: data.limit || 100,
order: data.sort ? [[data.sort.key, data.sort.asc ? 'ASC' : 'DESC']] : undefined
}); });
return new Response(JSON.stringify(users)); return new Response(JSON.stringify(users));
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => { export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });
@ -82,7 +89,7 @@ export const PATCH = (async ({ request, cookies }) => {
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const PUT = (async ({ request, cookies }) => { export const PUT = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });
@ -116,6 +123,9 @@ export const PUT = (async ({ request, cookies }) => {
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
console.error((e as Error).message); console.error((e as Error).message);
uuid = null; uuid = null;
} else if (e instanceof RateLimitError) {
console.error(`uuid request rate limited for user '${data.username}'`);
uuid = null;
} else { } else {
console.error((e as Error).message); console.error((e as Error).message);
throw error(500); throw error(500);
@ -151,7 +161,7 @@ export const PUT = (async ({ request, cookies }) => {
}) satisfies RequestHandler; }) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => { export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) { if (getSession(cookies, { permissions: [Permissions.Users] }) == null) {
return new Response(null, { return new Response(null, {
status: 401 status: 401
}); });

View File

@ -2,20 +2,34 @@
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
export let userFilter = { let {
name: null, userFilter = $bindable({ name: null, playertype: null }),
playertype: null onUpdate
}; }: { userFilter: { [k: string]: any }; onUpdate: () => void } = $props();
</script> </script>
<form class="flex flex-row justify-center items-center space-x-4 mx-4 my-2 w-full"> <form class="flex flex-row justify-center items-center space-x-4 my-2 w-full">
<div class="w-1/6"> <div class="w-full">
<Input size="sm" placeholder="..." bind:value={userFilter.name}> <Input
<span slot="label">Suche</span> size="sm"
placeholder="..."
bind:value={userFilter.name}
pickyWidth={false}
oninput={onUpdate}
>
{#snippet label()}
<span>Username</span>
{/snippet}
</Input> </Input>
</div> </div>
<div class="w-1/6"> <div class="w-full">
<Select label="Edition" size="sm" bind:value={userFilter.playertype}> <Select
label="Edition"
size="sm"
bind:value={userFilter.playertype}
pickyWidth={false}
onChange={onUpdate}
>
<option value={null}>Alle</option> <option value={null}>Alle</option>
<option value="java">Java</option> <option value="java">Java</option>
<option value="bedrock">Bedrock</option> <option value="bedrock">Bedrock</option>

View File

@ -3,16 +3,36 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
import { errorMessage } from '$lib/stores'; import { errorMessage } from '$lib/stores';
import { createEventDispatcher } from 'svelte'; import { getPopupModalShowFn } from '$lib/context';
const dispatch = createEventDispatcher(); let {
onSubmit
}: {
onSubmit: ({
firstname,
lastname,
birthday,
telephone,
username,
playertype
}: {
firstname: string;
lastname: string;
birthday: string;
telephone: string;
username: string;
playertype: string;
}) => void;
} = $props();
let firstname: string; let showPopupModal = getPopupModalShowFn();
let lastname: string;
let birthday: string; let firstname: string = $state('');
let phone: string; let lastname: string = $state('');
let username: string; let birthday: string = $state('');
let playertype = 'java'; let phone: string = $state('');
let username: string = $state('');
let playertype = $state('java');
async function newUser() { async function newUser() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, { const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
@ -27,7 +47,7 @@
}) })
}); });
if (response.ok) { if (response.ok) {
dispatch('submit', { onSubmit({
firstname: firstname, firstname: firstname,
lastname: lastname, lastname: lastname,
birthday: birthday, birthday: birthday,
@ -44,30 +64,42 @@
let globalCloseForm: HTMLFormElement; let globalCloseForm: HTMLFormElement;
let reportForm: HTMLFormElement; let reportForm: HTMLFormElement;
let confirmDialog: HTMLDialogElement;
</script> </script>
<form method="dialog" class="modal-box" bind:this={reportForm}> <form method="dialog" class="modal-box" bind:this={reportForm}>
<button <button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
on:click|preventDefault={() => globalCloseForm.submit()}>✕</button onclick={(e) => {
e.preventDefault();
globalCloseForm.submit();
}}>✕</button
> >
<h3 class="font-roboto text-xl">Neuer Spieler</h3> <h3 class="font-roboto text-3xl">Neuer Spieler</h3>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<Input type="text" required bind:value={firstname}> <Input type="text" required bind:value={firstname}>
<span slot="label">Vorname</span> {#snippet label()}
<span>Vorname</span>
{/snippet}
</Input> </Input>
<Input type="text" required bind:value={lastname}> <Input type="text" required bind:value={lastname}>
<span slot="label">Nachname</span> {#snippet label()}
<span>Nachname</span>
{/snippet}
</Input> </Input>
<Input type="date" required bind:value={birthday}> <Input type="date" required bind:value={birthday}>
<span slot="label">Geburtstag</span> {#snippet label()}
<span>Geburtstag</span>
{/snippet}
</Input> </Input>
<Input type="tel" bind:value={phone}> <Input type="tel" bind:value={phone}>
<span slot="label">Telefonnummer</span> {#snippet label()}
<span>Telefonnummer</span>
{/snippet}
</Input> </Input>
<Input type="text" required bind:value={username}> <Input type="text" required bind:value={username}>
<span slot="label">Minecraft-Spielername</span> {#snippet label()}
<span>Minecraft-Spielername</span>
{/snippet}
</Input> </Input>
<Select required label="Edition" bind:value={playertype}> <Select required label="Edition" bind:value={playertype}>
<option value="java">Java Edition</option> <option value="java">Java Edition</option>
@ -78,18 +110,21 @@
<Input <Input
type="submit" type="submit"
value="Hinzufügen" value="Hinzufügen"
on:click={(e) => { onclick={(e) => {
if (reportForm.checkValidity()) { if (reportForm.checkValidity()) {
e.detail.preventDefault(); e.preventDefault();
confirmDialog.show(); showPopupModal({
title: 'Spieler hinzufügen?',
actions: [{ text: 'Hinzufügen', action: newUser }, { text: 'Abbrechen' }]
});
} }
}} }}
/> />
<Input <Input
type="submit" type="submit"
value="Abbrechen" value="Abbrechen"
on:click={(e) => { onclick={(e) => {
e.detail.preventDefault(); e.preventDefault();
globalCloseForm.submit(); globalCloseForm.submit();
}} }}
/> />
@ -98,17 +133,3 @@
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}> <form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
<button>close</button> <button>close</button>
</form> </form>
<dialog class="modal" bind:this={confirmDialog}>
<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 mb-2">Spieler hinzufügen?</h3>
<div class="flex flex-row space-x-2 mt-6">
<Input type="submit" value="Hinzufügen" on:click={newUser} />
<Input type="submit" value="Abbrechen" />
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
</dialog>

View File

@ -8,7 +8,23 @@ export const UserListSchema = z.object({
playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(), playertype: z.enum(['java', 'bedrock', 'noauth']).nullish(),
search: z.string().nullish(), search: z.string().nullish(),
slim: z.boolean().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({ export const UserEditSchema = z.object({

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

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

View File

@ -5,8 +5,57 @@ import { env as public_env } from '$env/dynamic/public';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { ReportAddSchema } from './schema'; 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 }) => { export const POST = (async ({ request, url }) => {
if (env.REPORT_SECRET && url.searchParams.get('secret') !== env.REPORT_SECRET) if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
const parseResult = await ReportAddSchema.safeParseAsync(await request.json()); const parseResult = await ReportAddSchema.safeParseAsync(await request.json());

View File

@ -4,7 +4,7 @@ import { env } from '$env/dynamic/private';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
export const GET = (async ({ url }) => { export const GET = (async ({ url }) => {
if (env.REPORT_SECRET && url.searchParams.get('secret') !== env.REPORT_SECRET) if (env.API_SECRET && url.searchParams.get('secret') !== env.API_SECRET)
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
const uuid = url.searchParams.get('uuid'); const uuid = url.searchParams.get('uuid');

View File

@ -1,8 +0,0 @@
import type { PageServerLoad } from './$types';
import { Settings } from '$lib/server/database';
export const load: PageServerLoad = async () => {
return {
paypal_link: (await Settings.findOne({ where: { key: 'global.paypal_link' } }))?.value ?? ''
};
};

View File

@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import { env } from '$env/dynamic/public';
export let data: PageData;
let faq = [ let faq = [
{ {
@ -9,7 +7,7 @@
questions: [ questions: [
{ {
title: 'Wie kann ich einen Admin kontaktieren?', title: 'Wie kann ich einen Admin kontaktieren?',
content: `<p>Einen Admin kannst du im Chat, über WhatsApp, per Teamspeak (<a class="link" href="ts3server://mhsl.eu?port=9987">mhsl.eu</a>) oder Discord (<a class="link" href="https://discord.gg/EBGefWPc2K" target="_blank">https://discord.gg/EBGefWPc2K</a>) kontaktieren.</p>` 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?', title: 'Wer ist eigentlich Organisator und warum?',
@ -28,11 +26,11 @@ des Projekts.</p>`
}, },
{ {
title: 'Gibt es einen Teamspeak-Server?', title: 'Gibt es einen Teamspeak-Server?',
content: `<p>Ja, den offiziellen Teamspeak-Server erreichst du unter der IP <a class="link" href="ts3server://mhsl.eu?port=9987">mhsl.eu</a>.</p>` 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?', title: 'Gibt es einen Discord-Server?',
content: `<p>Ja, den offiziellen Discord-Server erreichst du unter <a class="link" href="https://discord.gg/EBGefWPc2K" target="_blank">https://discord.gg/EBGefWPc2K</a>.</p>` 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?', title: 'Wozu dient die CraftAttack-WhatsApp-Gruppe?',
@ -61,7 +59,7 @@ Gruppe beitreten.</p>`
}, },
{ {
title: 'Auf welcher Version läuft der Server?', title: 'Auf welcher Version läuft der Server?',
content: `<p>Gespielt wird immer auf der neuesten Version, also laut aktuellem Stand Version 1.21.3.</p>` 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?', title: 'Kann ich auch als Bedrock-Spieler (Handy oder Konsole) mitspielen?',
@ -82,7 +80,7 @@ dich jederzeit beim Admin-Team melden.</p>`
title: 'Ich komme nicht auf den Server, was kann ich tun?', title: 'Ich komme nicht auf den Server, was kann ich tun?',
content: `<p>Wenn du dem Server nicht beitreten kannst, überprüfe Folgendes:</p> content: `<p>Wenn du dem Server nicht beitreten kannst, überprüfe Folgendes:</p>
<ol class="list-decimal pl-8 py-3"> <ol class="list-decimal pl-8 py-3">
<li>Hast du die korrekte IP verwendet? Sie lautet <span class="underline italic">craftattack.mhsl.eu</span>.</li> <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>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>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> <li>Hast du dich korrekt auf der Webseite angemeldet?</li>
@ -92,14 +90,14 @@ Fehlermeldung bereit.</p>`
}, },
{ {
title: 'Was ist die Server-IP?', title: 'Was ist die Server-IP?',
content: `<p>Die Serveradresse lautet: <span class="underline italic">craftattack.mhsl.eu</span>.</p>` content: `<p>Die Serveradresse lautet: <span class="underline italic">${env.PUBLIC_SERVER_IP}</span>.</p>`
}, },
{ {
title: 'Ist es kostenlos mitzuspielen?', title: 'Ist es kostenlos mitzuspielen?',
content: `<p>Ja, die Teilnahme ist selbstverständlich kostenlos.${ content: `<p>Ja, die Teilnahme ist selbstverständlich kostenlos.${
data.paypal_link env.PUBLIC_PAYPAL_LINK
? `Wir freuen uns aber, wenn du das Projekt mit einer Spende nach der Anmeldung unterstützen würdest.<br> ? `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=${data.paypal_link} target="_blank">${data.paypal_link}</a>.` Hier kannst du für das Projekt spenden: <a class="link" href=${env.PUBLIC_PAYPAL_LINK} target="_blank">${env.PUBLIC_PAYPAL_LINK}</a>.`
: '' : ''
}</p>` }</p>`
}, },
@ -158,7 +156,7 @@ allerdings erst nach einigen Stunden wieder beendet werden.</p>`
wirkt sich allerdings nicht auf das Spielgeschehen aus.</p>` wirkt sich allerdings nicht auf das Spielgeschehen aus.</p>`
}, },
{ {
title: 'Was gibt es für neue coole Features?', title: 'Was gibt es für neue Features?',
content: `<ul class="list-disc pl-8"> content: `<ul class="list-disc pl-8">
<li>Miniböcke, die du selbst gestalten kannst</li> <li>Miniböcke, die du selbst gestalten kannst</li>
<li>Neue Event-Spiele</li> <li>Neue Event-Spiele</li>
@ -203,10 +201,11 @@ sind nicht gestattet.</p>`
<input type="checkbox" autocomplete="off" /> <input type="checkbox" autocomplete="off" />
<div class="collapse-title">{question.title}</div> <div class="collapse-title">{question.title}</div>
<div class="collapse-content"> <div class="collapse-content">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="ml-2">{@html question.content}</div> <div class="ml-2">{@html question.content}</div>
</div> </div>
</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} {/each}
</div> </div>
</div> </div>

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FeedbackSubmitSchema = z.object({
content: z.string(),
anonymous: z.boolean()
});

View File

@ -0,0 +1,6 @@
import { z } from 'zod';
export const FeedbackSubmitSchema = z.object({
content: z.string(),
type: z.enum(['feedback', 'contact'])
});

View File

@ -2,17 +2,16 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import RegistrationComplete from './RegistrationComplete.svelte'; import RegistrationComplete from './RegistrationComplete.svelte';
import Register from './Register.svelte'; import Register from './Register.svelte';
import type { PageData } from './$types';
let registered = false; let { data } = $props();
let firstname: string | null = null;
let lastname: string | null = null;
let birthday: Date | null = null;
let phone: string | null = null;
let username: string | null = null;
let edition: string | null = null;
export let data: PageData; 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> </script>
<svelte:head> <svelte:head>
@ -36,15 +35,15 @@
{#if !registered} {#if !registered}
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}> <div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
<Register <Register
on:submit={(e) => { submit={(e) => {
registered = true; registered = true;
firstname = e.detail.firstname; firstname = e.firstname;
lastname = e.detail.lastname; lastname = e.lastname;
birthday = e.detail.birthday; birthday = e.birthday;
phone = e.detail.phone; phone = e.phone;
phone = e.detail.phone; phone = e.phone;
username = e.detail.username; username = e.username;
edition = e.detail.edition; edition = e.edition;
}} }}
/> />
</div> </div>
@ -57,7 +56,7 @@
{phone} {phone}
{username} {username}
{edition} {edition}
on:close={() => (registered = false)} close={() => (registered = false)}
/> />
</div> </div>
{/if} {/if}

View File

@ -1,4 +1,10 @@
import { ApiError, getJavaUuid, getNoAuthUuid, UserNotFoundError } from '$lib/server/minecraft'; import {
ApiError,
getJavaUuid,
getNoAuthUuid,
RateLimitError,
UserNotFoundError
} from '$lib/server/minecraft';
import { error, type RequestHandler } from '@sveltejs/kit'; import { error, type RequestHandler } from '@sveltejs/kit';
import { Settings, User } from '$lib/server/database'; import { Settings, User } from '$lib/server/database';
import { RegisterSchema } from './schema'; import { RegisterSchema } from './schema';
@ -11,6 +17,7 @@ export const POST = (async ({ request }) => {
} }
try { try {
// eslint-disable-next-line no-var
var data = RegisterSchema.parse(await request.json()); var data = RegisterSchema.parse(await request.json());
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -44,13 +51,19 @@ export const POST = (async ({ request }) => {
} else if (e instanceof ApiError) { } else if (e instanceof ApiError) {
console.error((e as Error).message); console.error((e as Error).message);
uuid = null; uuid = null;
} else if (e instanceof RateLimitError) {
console.error(`uuid request rate limited for user '${data.username}'`);
uuid = null;
} else { } else {
console.error((e as Error).message); console.error((e as Error).message);
throw error(500); throw error(500);
} }
} }
if (uuid && (await User.findOne({ where: { uuid: uuid } }))) { if (
(uuid && (await User.findOne({ where: { uuid: uuid } }))) ||
(!uuid && (await User.findOne({ where: { username: data.username } })))
) {
throw error( throw error(
400, 400,
'Dein Minecraft-Account wurde bereits registriert.\n\nKontaktiere bitte einen Admin, falls diese ' + 'Dein Minecraft-Account wurde bereits registriert.\n\nKontaktiere bitte einen Admin, falls diese ' +

View File

@ -1,27 +1,57 @@
<script lang="ts"> <script lang="ts">
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
import Input from '$lib/components/Input/Input.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 { env } from '$env/dynamic/public';
import { rulesShort } from '$lib/rules'; import { rulesShort } from '$lib/rules';
import { RegisterSchema } from './schema'; import { RegisterSchema } from './schema';
import { dev } from '$app/environment'; 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();
let showPopupModal = getPopupModalShowFn();
const modalTimeoutSeconds = dev ? 0 : 30; const modalTimeoutSeconds = dev ? 0 : 30;
// eslint-disable-next-line @typescript-eslint/no-empty-function let checkInputs = $state(() => {});
let checkInputs = () => {}; let playertype = $state('java');
let playertype = 'java'; /* eslint-disable @typescript-eslint/ban-ts-comment */
let firstnameInput: HTMLInputElement; // @ts-ignore
let lastnameInput: HTMLInputElement; let firstnameInput: HTMLInputElement = $state();
let birthdayInput: HTMLInputElement; // @ts-ignore
let phoneInput: HTMLInputElement; let lastnameInput: HTMLInputElement = $state();
let usernameInput: HTMLInputElement; // @ts-ignore
let privacyInput: HTMLInputElement; let birthdayInput: HTMLInputElement = $state();
let logsInput: HTMLInputElement; // @ts-ignore
let rulesInput: HTMLInputElement; 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(() => { onMount(() => {
checkInputs = () => { checkInputs = () => {
let allInputs = [ let allInputs = [
@ -49,7 +79,7 @@
); );
if (!parseResult.success) { if (!parseResult.success) {
reject(Error(parseResult.error.issues.map((i) => i.message).join('\n\n'))); reject(Error(parseResult.error.issues.map((i) => i.message).join('\n')));
return; return;
} }
@ -58,10 +88,10 @@
body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0]))) body: JSON.stringify(Object.fromEntries(new FormData(document.forms[0])))
}); });
if (response.ok) { if (response.ok) {
dispatch('submit', { submit({
firstname: firstnameInput.value, firstname: firstnameInput.value,
lastname: lastnameInput.value, lastname: lastnameInput.value,
birthday: birthdayInput.valueAsDate, birthday: birthdayInput.valueAsDate!,
phone: phoneInput.value, phone: phoneInput.value,
username: usernameInput.value, username: usernameInput.value,
edition: playertype == 'java' ? 'Java (PC)' : 'Bedrock (Konsolen und Handys)' edition: playertype == 'java' ? 'Java (PC)' : 'Bedrock (Konsolen und Handys)'
@ -80,17 +110,32 @@
let rulesAccepted = false; let rulesAccepted = false;
let rulesModal: HTMLDialogElement; let rulesModal: HTMLDialogElement;
let rulesModalSecondsOpened = 0; let rulesModalSecondsOpened = $state(0);
// eslint-disable-next-line no-undef
let rulesModalTimer: number | NodeJS.Timeout | undefined = undefined; let rulesModalTimer: number | NodeJS.Timeout | undefined = undefined;
let inputsInvalidMessage: string | null = 'Bitte fülle alle erforderlichen Felder aus'; let inputsInvalidMessage: string | null = $state('Bitte fülle alle erforderlichen Felder aus');
let registerRequest: Promise<void> | null = null; let registerRequest: Promise<void> | null = $state(null);
let errorMessage: string = $state('');
let errorMessage: string = ''; $effect(() => {
if (!errorMessage) return;
showPopupModal({
title: 'Fehler',
text: errorMessage
});
});
</script> </script>
<h1 class="text-center text-3xl lg:text-5xl">Anmeldung</h1> <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="divider">Persönliche Angaben</div>
<div class="mx-2 grid grid-cols-1 sm:grid-cols-2 gap-y-4"> <div class="mx-2 grid grid-cols-1 sm:grid-cols-2 gap-y-4">
<Input <Input
@ -100,7 +145,9 @@
required={true} required={true}
bind:inputElement={firstnameInput} bind:inputElement={firstnameInput}
> >
<span slot="label">Vorname</span> {#snippet label()}
<span>Vorname</span>
{/snippet}
</Input> </Input>
<Input <Input
id="lastname" id="lastname"
@ -109,7 +156,9 @@
required={true} required={true}
bind:inputElement={lastnameInput} bind:inputElement={lastnameInput}
> >
<span slot="label">Nachname</span> {#snippet label()}
<span>Nachname</span>
{/snippet}
</Input> </Input>
<Input <Input
id="birthday" id="birthday"
@ -118,8 +167,12 @@
required={true} required={true}
bind:inputElement={birthdayInput} bind:inputElement={birthdayInput}
> >
<span slot="label">Geburtstag</span> {#snippet label()}
<span slot="notice">Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span> <span>Geburtstag</span>
{/snippet}
{#snippet notice()}
<span>Die Angabe hat keine Auswirkungen auf das Spielgeschehen</span>
{/snippet}
</Input> </Input>
<Input <Input
id="telephone" id="telephone"
@ -128,12 +181,16 @@
bind:inputElement={phoneInput} bind:inputElement={phoneInput}
pattern={new RegExp(/^[+()\s/\d]+$/)} pattern={new RegExp(/^[+()\s/\d]+$/)}
> >
<span slot="label">Telefonnummer</span> {#snippet label()}
<p slot="notice"> <span>Telefonnummer</span>
Diese nutzen wir, um Dich in der Whatsapp-Gruppe zuzuordnen und kontaktieren zu können. {/snippet}
<br /> {#snippet notice()}
<b>Die Angabe ist freiwillig, hilft den Administratoren jedoch sehr!</b> <p>
</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> </Input>
</div> </div>
<div class="divider">Spiel</div> <div class="divider">Spiel</div>
@ -145,7 +202,9 @@
required={true} required={true}
bind:inputElement={usernameInput} bind:inputElement={usernameInput}
> >
<span slot="label">Minecraft-Spielername</span> {#snippet label()}
<span>Minecraft-Spielername</span>
{/snippet}
</Input> </Input>
<Select <Select
id="playertype" id="playertype"
@ -158,7 +217,7 @@
<option value="bedrock">Bedrock Edition (Konsolen und Handys)</option> <option value="bedrock">Bedrock Edition (Konsolen und Handys)</option>
</Select> </Select>
</div> </div>
<div class="divider" /> <div class="divider"></div>
<div class="mx-2 grid gap-y-3 mb-6"> <div class="mx-2 grid gap-y-3 mb-6">
<div class="flex gap-4"> <div class="flex gap-4">
<Input <Input
@ -199,9 +258,9 @@
name="rules" name="rules"
type="checkbox" type="checkbox"
required={true} required={true}
on:input={(e) => { oninput={(e) => {
if (!rulesAccepted) { if (!rulesAccepted) {
e.detail.target.checked = false; e.currentTarget.checked = false;
rulesModal.show(); rulesModal.show();
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000); rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
} }
@ -211,7 +270,8 @@
<label for="rules"> <label for="rules">
Ich bin mit den <button Ich bin mit den <button
class="link" class="link"
on:click|preventDefault={() => { onclick={(e) => {
e.preventDefault();
rulesModal.show(); rulesModal.show();
rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000); rulesModalTimer = setInterval(() => rulesModalSecondsOpened++, 1000);
}}>Regeln</button }}>Regeln</button
@ -238,7 +298,7 @@
{#await registerRequest} {#await registerRequest}
<span <span
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring" class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
/> ></span>
{/await} {/await}
{/if} {/if}
{/key} {/key}
@ -247,7 +307,7 @@
<dialog <dialog
class="modal" class="modal"
on:close={() => { onclose={() => {
clearInterval(rulesModalTimer); clearInterval(rulesModalTimer);
rulesModalTimer = undefined; rulesModalTimer = undefined;
}} }}
@ -266,7 +326,7 @@
<p>{rulesShort.header}</p> <p>{rulesShort.header}</p>
<p class="mt-1 text-[.75rem]">{rulesShort.footer}</p> <p class="mt-1 text-[.75rem]">{rulesShort.footer}</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>
</div> </div>
{#each rulesShort.sections as section, i} {#each rulesShort.sections as section, i}
<div class="collapse collapse-arrow"> <div class="collapse collapse-arrow">
@ -278,10 +338,10 @@
<p>{section.content}</p> <p>{section.content}</p>
</div> </div>
</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} {/each}
</div> </div>
<!-- svelte-ignore a11y-no-static-element-interactions a11y-click-events-have-key-events --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
class="relative w-min" class="relative w-min"
title={rulesModalSecondsOpened < modalTimeoutSeconds title={rulesModalSecondsOpened < modalTimeoutSeconds
@ -290,7 +350,7 @@
0 0
)} Sekunden akzeptiert werden` )} Sekunden akzeptiert werden`
: ''} : ''}
on:click={() => { onclick={() => {
if (rulesModalSecondsOpened < modalTimeoutSeconds) { if (rulesModalSecondsOpened < modalTimeoutSeconds) {
errorMessage = errorMessage =
'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.'; 'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.';
@ -301,7 +361,7 @@
<div <div
style="width: {Math.min((rulesModalSecondsOpened / modalTimeoutSeconds) * 100, 100)}%" style="width: {Math.min((rulesModalSecondsOpened / modalTimeoutSeconds) * 100, 100)}%"
class="h-full bg-base-300" class="h-full bg-base-300"
/> ></div>
</div> </div>
<Input <Input
id="rules-accept" id="rules-accept"
@ -309,7 +369,7 @@
value="Akzeptieren" value="Akzeptieren"
disabled={rulesModalSecondsOpened < modalTimeoutSeconds} disabled={rulesModalSecondsOpened < modalTimeoutSeconds}
containerClass="bg-transparent z-[1] relative" containerClass="bg-transparent z-[1] relative"
on:click={() => { onclick={() => {
rulesAccepted = true; rulesAccepted = true;
rulesInput.checked = true; rulesInput.checked = true;
checkInputs(); checkInputs();
@ -322,16 +382,3 @@
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>
{#if errorMessage}
<dialog class="modal" on:close={() => setTimeout(() => (errorMessage = ''), 200)} open>
<form method="dialog" class="modal-box z-50">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 class="font-bold text-2xl">Achtung</h3>
<p class="py-4 whitespace-pre-line">{errorMessage}</p>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
<button>close</button>
</form>
</dialog>
{/if}

View File

@ -1,17 +1,26 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'; import { onMount } from 'svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
export let firstname: string; let {
export let lastname: string; firstname,
export let birthday: Date; lastname,
export let phone: string | null; birthday,
export let username: string; phone,
export let edition: string; username,
edition,
const dispatch = createEventDispatcher(); close
}: {
firstname: string;
lastname: string;
birthday: Date;
phone?: string;
username: string;
edition: string;
close: () => void;
} = $props();
let startDayOptions: Intl.DateTimeFormatOptions = { let startDayOptions: Intl.DateTimeFormatOptions = {
day: '2-digit', day: '2-digit',
@ -22,7 +31,7 @@
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}; };
let skin: string | null = null; let skin: string | null = $state(null);
onMount(async () => { onMount(async () => {
let skinview3d = await import('skinview3d'); let skinview3d = await import('skinview3d');
@ -59,16 +68,45 @@
>. >.
</p> </p>
<p>Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p> <p>Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
<div class="divider" /> <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="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"> <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" readonly><span slot="label">Vorname</span></Input> <Input value={firstname} size="sm" disabled>
<Input value={lastname} size="sm" readonly><span slot="label">Nachname</span></Input> {#snippet label()}
<Input value={birthday.toISOString().substring(0, 10)} type="date" size="sm" readonly <span>Vorname</span>
><span slot="label">Geburtstag</span></Input {/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" readonly><span slot="label">Telefonnummer</span></Input> <Input value={phone} size="sm" disabled>
<Input value={username} size="sm" readonly><span slot="label">Spielername</span></Input> {#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"> <Select value="edition" size="sm" disabled label="Edition">
<option value="edition">{edition}</option> <option value="edition">{edition}</option>
</Select> </Select>
@ -77,11 +115,11 @@
{#if skin} {#if skin}
<img class="absolute" src={skin} alt="" /> <img class="absolute" src={skin} alt="" />
{:else} {:else}
<span class="loading loading-spinner loading-lg" /> <span class="loading loading-spinner loading-lg"></span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="divider" /> <div class="divider"></div>
<div class="flex justify-center gap-8"> <div class="flex justify-center gap-8">
<button class="btn" on:click={() => dispatch('close')}>Weitere Person anmelden</button> <button class="btn" onclick={close}>Weitere Person anmelden</button>
</div> </div>

View File

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

View File

@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
export const load: PageLoad = async () => {
throw redirect(302, `${env.PUBLIC_BASE_PATH}/`);
};

View File

@ -1,43 +0,0 @@
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';
export const POST = (async ({ request, url }) => {
if (env.REPORT_SECRET && url.searchParams.get('secret') !== env.REPORT_SECRET)
return new Response(null, { status: 401 });
const data: { reporter: string; reported: string | null; reason: string } = await request.json();
if (data.reporter == null || data.reason == null) return new Response(null, { status: 400 });
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;

View File

@ -1,3 +1,7 @@
<div class="flex justify-center items-center w-full min-h-screen h-full"> <script lang="ts">
<slot /> let { children } = $props();
</script>
<div class="flex justify-center items-center w-full">
{@render children()}
</div> </div>

View File

@ -18,6 +18,8 @@ export const load: PageServerLoad = async ({ params }) => {
draft: report.draft, draft: report.draft,
status: report.status, status: report.status,
reason: report.subject, reason: report.subject,
body: report.body,
statement: report.statement,
reporter: { reporter: {
name: report.reporter.username name: report.reporter.username
}, },

View File

@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types';
import ReportDraft from './ReportDraft.svelte'; import ReportDraft from './ReportDraft.svelte';
import ReportCompleted from './ReportCompleted.svelte';
import ReportSubmitted from './ReportSubmitted.svelte'; import ReportSubmitted from './ReportSubmitted.svelte';
export let data: PageData; let { data } = $props();
let reason = $state(data.reason);
let body = $state(data.body);
let reporterName = $state(data.reporter.name);
let reportedName = $state(data.reported.name || null);
let completed = $state(!data.draft);
</script> </script>
<svelte:head> <svelte:head>
@ -14,22 +18,28 @@
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
</svelte:head> </svelte:head>
<div class="absolute top-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg"> <div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
{#if data.draft} {#if !completed}
<div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}> <div class="col-[1] row-[1]" transition:fly={{ x: -200, duration: 300 }}>
<ReportDraft <ReportDraft
reason={data.reason} bind:reason
reporterName={data.reporter.name} bind:body
reportedName={data.reported.name ?? undefined} bind:reporterName
bind:reportedName
users={data.users ?? []} users={data.users ?? []}
on:submit={() => (data.draft = false)} onsubmit={() => (completed = true)}
/> />
</div> </div>
{:else if data.status === 'reviewed'}
<ReportCompleted />
{:else} {:else}
<div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}> <div class="col-[1] row-[1]" transition:fly={{ x: 200, duration: 300 }}>
<ReportSubmitted /> <ReportSubmitted
{reason}
{body}
{reporterName}
{reportedName}
status={data.status}
statement={data.statement}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -1,3 +0,0 @@
<div>
<h2 class="text-xl text-center">Dieser Report wurde von einem Admin bearbeitet</h2>
</div>

View File

@ -3,29 +3,35 @@
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { createEventDispatcher } from 'svelte';
import Search from '$lib/components/Input/Search.svelte'; import Search from '$lib/components/Input/Search.svelte';
import Select from '$lib/components/Input/Select.svelte'; import Select from '$lib/components/Input/Select.svelte';
import { getPopupModalShowFn } from '$lib/context';
export let reporterName: string; let {
export let reportedName: string | null; reporterName = $bindable(),
export let reason: string; reportedName = $bindable(null),
export let users: string[]; reason = $bindable(),
body = $bindable(),
users,
onsubmit
}: {
reporterName: string;
reportedName: string | null;
reason: string;
body: string;
users: string[];
onsubmit: () => void;
} = $props();
let oldReported = reportedName; let showPopupModal = getPopupModalShowFn();
$: reportedName = oldReported;
let body: string; let reported = $state(reportedName);
let userErrorModal: HTMLDialogElement;
let submitModal: HTMLDialogElement;
let dispatch = createEventDispatcher();
async function submitReport() { async function submitReport() {
await fetch(`${env.PUBLIC_BASE_PATH}/report/${$page.params.url_hash}`, { await fetch(`${env.PUBLIC_BASE_PATH}/report/${$page.params.url_hash}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
reported: reportedName || null, reported: reported || null,
subject: reason, subject: reason,
body: body body: body
}) })
@ -45,35 +51,57 @@
<div> <div>
<h2 class="text-3xl text-center"> <h2 class="text-3xl text-center">
Report von <span class="underline">{reporterName}</span> gegen Report von <span class="underline">{reporterName}</span> gegen
<span class="underline">{(reportedName ?? 'unbekannt') || oldReported}</span> <span class="underline">{reported !== null ? reported : 'unbekannt'}</span>
</h2> </h2>
<form <form
on:submit|preventDefault={() => { onsubmit={(e) => {
if (reportedName != null && users.findIndex((u) => u === reportedName) === -1) { e.preventDefault();
userErrorModal.show(); if (reported != null && users.findIndex((u) => u === reported) === -1) {
showPopupModal({
title: 'Fehler',
text: 'Der zu reportende Spieler existiert nicht.',
actions: [{ text: 'Schließen' }]
});
} else { } else {
submitModal.show(); showPopupModal({
title: 'Report abschicken?',
text: 'Nach dem Abschicken des Reports wird sich ein Admin schnellstmöglich darum kümmern.',
actions: [
{
text: 'Abschicken',
action: async () => {
await submitReport();
onsubmit();
}
},
{ text: 'Abbrechen' }
]
});
} }
}} }}
> >
<div class="space-y-4 my-4"> <div class="space-y-4 my-4">
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<Select <Select
value={+(reportedName === null)} value={+(reported === null)}
size="sm" size="sm"
pickyWidth={false} pickyWidth={false}
on:change={(e) => (reportedName = e.detail.value === 0 ? '' : null)} onChange={(e) => {
reported = e.value === 0 ? reportedName || '' : null;
}}
> >
<option value={0}>Ich möchte einen bestimmten Spieler reporten</option> <option value={0}>Ich möchte einen bestimmten Spieler reporten</option>
<option value={1}>Ich möchte einen unbekannten Spieler reporten</option> <option value={1}>Ich möchte einen unbekannten Spieler reporten</option>
</Select> </Select>
{#if reportedName !== null} {#if reported !== null}
<Search size="sm" bind:value={oldReported} searchSuggestionFunc={suggestNames} /> <Search size="sm" bind:value={reported} searchSuggestionFunc={suggestNames} />
{/if} {/if}
</div> </div>
<div> <div>
<Input type="text" bind:value={reason} required={true} pickyWidth={false}> <Input type="text" bind:value={reason} required={true} pickyWidth={false}>
<span slot="label">Report Grund</span> {#snippet label()}
<span>Report Grund</span>
{/snippet}
</Input> </Input>
</div> </div>
<div> <div>
@ -90,47 +118,3 @@
</div> </div>
</form> </form>
</div> </div>
<dialog class="modal" bind:this={userErrorModal}>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div>
<h3 class="font-roboto font-medium text-xl">Fehler</h3>
<div class="my-4">
<p>Der zu reportende Spieler existiert nicht</p>
</div>
<div class="flex flex-row space-x-1">
<Input type="submit" value="Schließen" />
</div>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
</dialog>
<dialog class="modal" bind:this={submitModal}>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div>
<h3 class="font-roboto font-medium text-xl">Report abschicken?</h3>
<div class="my-4">
<p>Nach dem Abschicken des Reports wird sich ein Admin schnellstmöglich darum kümmern.</p>
</div>
<div class="flex flex-row space-x-1">
<Input
type="submit"
value="Abschicken"
on:click={async () => {
await submitReport();
dispatch('submit');
}}
/>
<Input type="submit" value="Abbrechen" />
</div>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button>close</button>
</form>
</dialog>

View File

@ -1,6 +1,58 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import Textarea from '$lib/components/Input/Textarea.svelte';
let {
reporterName,
reportedName,
status,
reason,
body,
statement
}: {
reporterName: string;
reportedName: string | null;
status: 'none' | 'review' | 'reviewed';
reason: string;
body: string;
statement?: string;
} = $props();
</script>
<div> <div>
<h2 class="text-2xl text-center">Report abgeschickt</h2> <h2 class="text-3xl text-center">
<p class="mt-4"> Report von <span class="underline">{reporterName}</span> gegen
Dein Report wurde abgeschickt und wird so schnell wie möglich von einem Admin bearbeitet. <span class="underline">{reportedName ?? 'unbekannt'}</span>
</p> </h2>
<h4 class="text-xl text-center mt-1 mb-0">
{#if status === 'none'}
<span>Unbearbeitet</span>
{:else if status === 'review'}
<span>In Bearbeitung</span>
{:else if status === 'reviewed'}
<span>Bearbeitet</span>
{/if}
<span class="relative inline-flex h-3 w-3 ml-[1px]">
{#if status === 'review'}
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-500 opacity-75"
></span>
{/if}
<span
class="relative inline-flex rounded-full h-3 w-3"
class:bg-yellow-500={status === 'none' || status === 'review'}
class:bg-green-500={status === 'reviewed'}
></span>
</span>
</h4>
<div class="my-4">
<Input type="text" size="sm" value={reason} disabled={true} pickyWidth={false}>
{#snippet label()}
<span>Report Grund</span>
{/snippet}
</Input>
<Textarea disabled={true} rows={4} value={body} label="Report Details" />
</div>
<div class="divider divider-vertical mb-1"></div>
<Textarea disabled={true} rows={4} label="Admin Kommentar" value={statement} />
</div> </div>

View File

@ -1,3 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="mx-4 my-6 sm:mx-48 sm:my-12"> <div class="mx-4 my-6 sm:mx-48 sm:my-12">
<slot /> {@render children()}
</div> </div>

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { rulesLong, rulesShort } from '$lib/rules'; import { rulesLong } from '$lib/rules';
</script> </script>
<svelte:head> <svelte:head>
@ -14,12 +14,12 @@
<p>0. Vorwort</p> <p>0. Vorwort</p>
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<p>{rulesShort.header}</p> <p>{rulesLong.header}</p>
<p class="mt-1 text-[.75rem]">{rulesShort.footer}</p> <p class="mt-1 text-[.75rem]">{rulesLong.footer}</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>
</div> </div>
{#each rulesShort.sections as section, i} {#each rulesLong.sections as section, i}
<div class="collapse collapse-arrow"> <div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" /> <input type="checkbox" autocomplete="off" />
<div class="collapse-title"> <div class="collapse-title">
@ -29,5 +29,5 @@
<p>{section.content}</p> <p>{section.content}</p>
</div> </div>
</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} {/each}

View File

@ -1,3 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="m-auto flex flex-col justify-center items-center px-4 py-6 2xl:px-48 sm:py-12"> <div class="m-auto flex flex-col justify-center items-center px-4 py-6 2xl:px-48 sm:py-12">
<slot /> {@render children()}
</div> </div>

View File

@ -32,7 +32,10 @@
{ {
name: 'Lars', name: 'Lars',
nickname: '28Pupsi28', nickname: '28Pupsi28',
roles: ['Softwareentwicklung'] roles: ['Softwareentwicklung'],
links: [
{ name: 'Website', href: 'https://mathemann.ddns.net/turtle_game/', icon: globe_icon }
]
} }
]; ];
</script> </script>
@ -45,7 +48,7 @@
<h1 class="text-5xl mb-10">Das Team</h1> <h1 class="text-5xl mb-10">Das Team</h1>
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4 my-4 justify-center"> <div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4 my-4 justify-center">
{#each team as member} {#each team as member}
<div class="card w-96 bg-base-200"> <div class="card max-w-96 bg-base-200">
<div class="card-body px-4 py-6"> <div class="card-body px-4 py-6">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="avatar placeholder mb-2"> <div class="avatar placeholder mb-2">
@ -61,7 +64,7 @@
<p class="text-center text-sm font-light">{member.roles.join(' · ')}</p> <p class="text-center text-sm font-light">{member.roles.join(' · ')}</p>
{#if member.links} {#if member.links}
<div class="w-full flex items-center flex-col"> <div class="w-full flex items-center flex-col">
<div class="w-1/2 h-[1px] my-3 rounded bg-base-content" /> <div class="w-1/2 h-[1px] my-3 rounded bg-base-content"></div>
<div class="flex gap-3"> <div class="flex gap-3">
{#each member.links as link} {#each member.links as link}
<a <a

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

BIN
static/img/logo-512.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

BIN
static/img/menu-button.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 B

BIN
static/img/menu-faq.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 B

BIN
static/img/menu-home.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

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