Compare commits

...

85 Commits

Author SHA1 Message Date
2f6b3521cd add crown to winner team
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 27s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-07-02 17:55:06 +02:00
6789a65285 add death to admin ui
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-25 16:08:03 +02:00
7a0db65f78 add copy public report button
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 20s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 22s
2025-06-25 14:46:16 +02:00
94aa6ea377 fix invalid date on admin ui report create 2025-06-25 14:26:21 +02:00
a06cc34085 fix creation date not set if finished report is added via api
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 17:24:24 +02:00
9041578252 do not show killer name on team member hover
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 01:16:00 +02:00
deafb65c75 add admin tools
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 01:13:34 +02:00
2eb9891b3c add id to strike reasons 2025-06-24 00:39:47 +02:00
36fe39845f fix member kills in teams table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 00:32:06 +02:00
d82ac4f275 fix team sorting
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 28s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 00:31:14 +02:00
136e0b808c fix wrong permission checks
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-24 00:29:52 +02:00
d29e761efb fix admin layout showing inaccessible routes
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 16s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-24 00:27:18 +02:00
018e239c35 fix top margin 2025-06-24 00:25:32 +02:00
1f96e3babe show kill details on team member hover instead of kill hover
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 00:22:11 +02:00
e5f253ebc1 show kill details on kill number hover
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 24s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-24 00:00:05 +02:00
6e4a7f0ac9 update team sorting
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 20s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-23 21:12:00 +02:00
55f5f25e5a show death message on dead user hover 2025-06-23 20:27:49 +02:00
03ee87d7cf fix bit badge value not updating 2025-06-23 20:02:46 +02:00
9a6e44b2d5 show report attachments in admin ui
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 15s
2025-06-22 16:29:48 +02:00
1a81b5fb06 send webhook on finished api report
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 25s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-22 13:53:28 +02:00
d7b05deff2 edit reported team in admin ui
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 28s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-22 00:11:10 +02:00
e9e44f67a2 update webhook keepalive 2025-06-21 23:51:52 +02:00
eeeca4ed4e set webhook content type
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 20s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 23:45:37 +02:00
7b5557bd76 show color if team is dead
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 25s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-21 23:42:54 +02:00
29a80935ff make webhook post
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 19s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 23:40:29 +02:00
023fd67004 show team kills
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 23:18:46 +02:00
daa1de302b update admin team table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 31s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-21 23:03:16 +02:00
e0c91483fb remove color if team is dead
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 22:46:02 +02:00
bff1a4bda6 strike through team name if both members are dead
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 17s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 18s
2025-06-21 22:40:54 +02:00
46494ed8dc do not show team text when signup is deactivated 2025-06-21 22:39:02 +02:00
4e615fe211 fix report edit 2025-06-21 22:37:19 +02:00
54a780d999 fix admin ui strike submit
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 22:08:44 +02:00
94e9e83e93 check file size in server action
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 21s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 21:54:01 +02:00
eb39cae44c show date in admin ui report table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 24s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-21 21:25:47 +02:00
e0b9850efb fix admin ui report table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 20s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-21 21:17:34 +02:00
12f8b9c43d update dropzone supported mime types and popup message
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 13s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-21 17:01:39 +02:00
faa3eaa007 typescript magic 2025-06-21 16:48:26 +02:00
9092012cf7 fix report status error
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-21 16:43:51 +02:00
ee8f595ecc add feedback and report things
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 21s
2025-06-21 14:46:42 +02:00
9c49585873 fix settings not saved
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-19 19:28:23 +02:00
28951534ee make settings keys unique
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 13s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-19 19:23:39 +02:00
17f32d6d91 update faq
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-19 15:57:08 +02:00
c6f8053468 update rules 2025-06-19 15:56:03 +02:00
ab46e00c71 fix invalid input minecraft uuid search
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-19 15:15:57 +02:00
afcff1959d make admins head url lowercase
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 21s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-16 20:20:46 +02:00
e22d3fd7e6 set teams table color min width and height
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 20s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-16 20:14:02 +02:00
bf5e144e55 break text in teams table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-16 20:12:19 +02:00
3f28377f57 set fix team table row width
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 15s
2025-06-16 20:03:56 +02:00
6051c4dd69 show missing players in team table
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-16 13:16:53 +02:00
8cc103510c align admin dashboard settings
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 16s
2025-06-16 13:05:36 +02:00
019ef4d444 add signup info message
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 22s
2025-06-16 13:01:08 +02:00
b10f400e4a update java uuid query
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-12 23:17:19 +02:00
e04d1bf7bf fix strike query
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 15s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-10 20:24:24 +02:00
0a513d2350 do not allow sign in if team member is blocked
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 14s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 14s
2025-06-10 11:50:52 +02:00
057a287277 fix admin dashboard team edit 2025-06-10 11:50:32 +02:00
5c41857530 update reports
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 21s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-10 00:11:13 +02:00
8262fd90aa show member name gray in admin dashboard if not signed up 2025-06-09 23:52:46 +02:00
56deba780d fix admin dashboard team edit
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 18s
2025-06-09 23:02:49 +02:00
5a8203d122 fix stretching team member heads 2025-06-09 22:34:22 +02:00
aacc815676 update comment position 2025-06-09 21:54:51 +02:00
afd3541d4d trim all text inputs on signup actions
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-09 21:36:45 +02:00
e770a72f9d fix admin panel user add having an invalid id 2025-06-09 20:42:37 +02:00
506f8646cb fix jsx and import
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 21s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 13s
2025-06-09 20:31:53 +02:00
acb74e3d29 add opengraph tags
Some checks failed
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Failing after 9s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Failing after 16s
2025-06-09 19:24:11 +02:00
ed43212d9c add optional youtube intro link
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 27s
2025-06-09 18:44:47 +02:00
86f448a6e3 update texts
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 16s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-09 16:10:30 +02:00
b8ed48a68b make admin cookie name variable
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 24s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 23s
2025-06-09 15:45:21 +02:00
eb45e03f16 update path building
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 23s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 22s
2025-06-09 15:38:43 +02:00
11b0ee4083 update deployment pipeline
All checks were successful
deploy / build-and-deploy (/opt/website, website) (push) Successful in 28s
deploy / build-and-deploy (/opt/website-test, website-test) (push) Successful in 14s
2025-06-09 15:18:54 +02:00
564aa9eeda fix incorrect name on invalid signup team member 2025-06-09 14:49:45 +02:00
c1c5af73dd sort team table
All checks were successful
deploy / build-and-deploy (push) Successful in 23s
2025-06-04 00:46:11 +02:00
fc156c386b show user heads in team table
All checks were successful
deploy / build-and-deploy (push) Successful in 16s
2025-06-04 00:13:59 +02:00
a8a6424098 update teams table 2025-06-03 23:59:28 +02:00
c1dcaf05bf update signup name validation pattern 2025-06-03 23:50:09 +02:00
56e0e15a04 make astro module scripts view transition aware 2025-06-03 23:28:31 +02:00
e09a232f3c cancel subscriptions on signup popup destroy 2025-06-03 23:28:12 +02:00
54352d7b73 fix registered popup not reset state on close 2025-06-03 22:48:14 +02:00
b8112d66db fix rules popup having timeout in dev mode 2025-06-03 22:47:39 +02:00
cb0c453279 update team popup text 2025-06-03 22:47:25 +02:00
1d43eb8b90 add incomplete team notice 2025-06-03 22:47:10 +02:00
6a9f88c9f7 rename team to admins 2025-06-03 22:05:03 +02:00
dc9ef2bb84 check team member uuid on signup 2025-06-03 21:59:00 +02:00
95daaddb9b show the required read time in the rules popup more clearly
All checks were successful
deploy / build-and-deploy (push) Successful in 13s
2025-05-31 14:52:13 +02:00
c177832fbe fix no error popup shown if a signup user wants to be in a team with themselves
All checks were successful
deploy / build-and-deploy (push) Successful in 16s
2025-05-31 14:07:15 +02:00
6aa492cf42 update register popup 2025-05-31 14:05:36 +02:00
84 changed files with 2500 additions and 491 deletions

View File

@ -4,10 +4,14 @@ DATABASE_URI=mysql://website:website@localhost:3306/website
ADMIN_USER=admin
ADMIN_PASSWORD=admin
ADMIN_COOKIE=muelleel
UPLOAD_PATH=/tmp
YOUTUBE_INTRO_LINK=https://www.youtube-nocookie.com/embed/e78_QbTNb4s
TEAMSPEAK_LINK=http://example.com
DISCORD_LINK=http://example.com
PAYPAL_LINK=http://example.com
SERVER_IP=1.1.1.1
BASE_PATH=http://localhost:4321/varo
BASE_PATH=http://localhost:4321

View File

@ -6,14 +6,33 @@ on:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- dir: /opt/website
base_path: /varo
service: website
- dir: /opt/website-test
base_path: /testvaro
service: website-test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache
uses: actions/cache@v4
with:
path: |
dist/
node_modules/
key: ${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
run: npm i
- name: Build website
env:
BASE_PATH: ${{ matrix.base_path }}
run: npm run build
- name: Deploy
@ -21,6 +40,8 @@ jobs:
HOST: ${{ secrets.SSH_HOST}}
USER: ${{ secrets.SSH_USER }}
SSH_KEY: ${{ secrets.SSH_KEY }}
DIRECTORY: ${{ matrix.dir }}
SERVICE: ${{ matrix.service }}
run: |
mkdir -p "$HOME/.ssh" && touch "$HOME/.ssh/known_hosts"
echo "$SSH_KEY" > "$HOME/.ssh/deploy_key"
@ -29,6 +50,6 @@ jobs:
ssh-add "$HOME/.ssh/deploy_key"
ssh-keyscan -t rsa "$HOST" >> "$HOME/.ssh/known_hosts"
ssh -o StrictHostKeyChecking=no $USER@$HOST "rm -r /opt/website; mkdir -p /opt/website"
scp -r -o StrictHostKeyChecking=no $(ls -d -1 dist/*) $(ls package*) $USER@$HOST:/opt/website
ssh -o StrictHostKeyChecking=no $USER@$HOST "cd /opt/website; npm i --omit=dev; systemctl restart website"
ssh -o StrictHostKeyChecking=no $USER@$HOST "rm -r $DIRECTORY; mkdir -p $DIRECTORY"
scp -r -o StrictHostKeyChecking=no $(ls -d -1 dist/*) $(ls package*) $USER@$HOST:$DIRECTORY
ssh -o StrictHostKeyChecking=no $USER@$HOST "cd $DIRECTORY; npm i --omit=dev; systemctl restart $SERVICE"

View File

@ -41,6 +41,79 @@
</details>
<details>
<summary><code>POST</code> <code>/api/report</code> (Erstellt einen Report)</summary>
##### Request Body
```
{
// UUID des Report Erstellers
"reporter": string,
// UUID des Reporteten Spielers
"reported": string | null,
// Report Grund
"reason": string
}
```
##### Response Codes
| http code | beschreibung |
| --------- | ----------------------------------------------------------------- |
| 200 | / |
| 400 | Der Request Body ist falsch |
| 401 | Es wurde ein falsches API Secret angegeben |
| 404 | Der Report Ersteller, oder der reportete Spieler, existiert nicht |
##### Response Body
```
{
// URL, wo der Ersteller den Report abschicken kann
"url": string
}
```
</details>
<details>
<summary><code>PUT</code> <code>/api/report</code> (Erstellt einen Abgeschlossenen Report)</summary>
##### Request Body
```
{
// UUID des Reporters. Wenn `null`, wird der Reporter als System interpretiert
"reporter": string | null,
// UUID des Reporteten Spielers
"reported": string,
// Report Grund
"reason": string,
// Inhalt des Reports
"body": string | null,
// Interne Notiz
"notice": string | null,
// Öffentliches Statement
"statement": string | null,
// ID des Strikegrundes
"strike_reason_id": number
}
```
| http code | beschreibung |
| --------- | ----------------------------------------------------------------- |
| 200 | / |
| 400 | Der Request Body ist falsch |
| 401 | Es wurde ein falsches API Secret angegeben |
| 404 | Der Report Ersteller, oder der reportete Spieler, existiert nicht |
##### Response Body
`/`
</details>
<details>
<summary><code>POST</code> <code>/api/player/death</code> (Registriert einen Spielertod)</summary>

View File

@ -14,7 +14,7 @@ import inoxToolsRuntimeLogger from '@inox-tools/runtime-logger';
export default defineConfig({
output: 'server',
prefetch: true,
base: '/varo',
base: process.env.BASE_PATH ?? undefined,
security: {
checkOrigin: false
@ -36,11 +36,17 @@ export default defineConfig({
ADMIN_USER: envField.string({ context: 'server', access: 'secret', optional: true }),
ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }),
UPLOAD_PATH: envField.string({ context: 'server', access: 'secret', optional: true }),
MAX_UPLOAD_BYTES: envField.number({ context: 'server', access: 'secret', default: 20 * 1024 * 1024 }),
START_DATE: envField.string({ context: 'server', access: 'secret', default: '1970-01-01' }),
WEBHOOK_ENDPOINT: envField.string({ context: 'server', access: 'secret', optional: true }),
YOUTUBE_INTRO_LINK: envField.string({ context: 'server', access: 'secret', optional: true }),
TEAMSPEAK_LINK: envField.string({ context: 'server', access: 'secret', default: 'http://example.com' }),
DISCORD_LINK: envField.string({ context: 'server', access: 'secret', default: 'http://example.com' }),
PAYPAL_LINK: envField.string({ context: 'server', access: 'secret', default: 'http://example.com' }),

View File

@ -28,6 +28,15 @@ export const feedback = {
});
}
}),
submitFeedback: defineAction({
input: z.object({
urlHash: z.string(),
content: z.string()
}),
handler: async (input) => {
await db.submitFeedback(input);
}
}),
feedbacks: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Feedback);

View File

@ -6,6 +6,7 @@ import { team } from './team.ts';
import { settings } from './settings.ts';
import { feedback } from './feedback.ts';
import { report } from './report.ts';
import { tools } from './tools.ts';
export const server = {
admin,
@ -15,5 +16,6 @@ export const server = {
user,
report,
feedback,
settings
settings,
tools
};

View File

@ -1,10 +1,110 @@
import { defineAction } from 'astro:actions';
import { ActionError, defineAction } from 'astro:actions';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { db } from '@db/database.ts';
import { z } from 'astro:schema';
import { MAX_UPLOAD_BYTES, UPLOAD_PATH } from 'astro:env/server';
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
export const report = {
submitReport: defineAction({
input: z.object({
urlHash: z.string(),
reason: z.string(),
body: z.string(),
files: z
.array(
z
.instanceof(File)
.refine((f) => [...allowedImageTypes, ...allowedVideoTypes].findIndex((v) => v === f.type) !== -1)
)
.nullable()
}),
handler: async (input) => {
const fileSize = input.files?.reduce((prev, curr) => prev + curr.size, 0);
if (fileSize && fileSize > MAX_UPLOAD_BYTES) {
throw new ActionError({
code: 'BAD_REQUEST'
});
}
const report = await db.getReportByUrlHash({ urlHash: input.urlHash });
if (!report) {
throw new ActionError({
code: 'NOT_FOUND'
});
}
if (!UPLOAD_PATH) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Es dürfen keine Anhänge hochgeladen werden'
});
}
const filePaths = [] as string[];
try {
await db.transaction(async (tx) => {
for (const file of input.files ?? []) {
const uuid = crypto.randomUUID();
const tmpFilePath = path.join(UPLOAD_PATH!, uuid);
const tmpFileStream = fs.createWriteStream(tmpFilePath);
filePaths.push(tmpFilePath);
const md5Hash = crypto.createHash('md5');
for await (const chunk of file.stream()) {
md5Hash.update(chunk);
tmpFileStream.write(chunk);
}
const hash = md5Hash.digest('hex');
const filePath = path.join(UPLOAD_PATH!, hash);
let type: 'image' | 'video';
if (allowedImageTypes.includes(file.type)) {
type = 'image';
} else if (allowedVideoTypes.includes(file.type)) {
type = 'video';
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Invalid file type'
});
}
await tx.addReportAttachment({
type: type,
hash: hash,
reportId: report.id
});
fs.renameSync(tmpFilePath, filePath);
filePaths.pop();
filePaths.push(filePath);
}
await tx.submitReport({
urlHash: input.urlHash,
reason: input.reason,
body: input.body
});
});
} catch (e) {
for (const filePath of filePaths) {
fs.rmSync(filePath);
}
throw e;
}
},
accept: 'form'
}),
addReport: defineAction({
input: z.object({
reason: z.string(),
@ -19,7 +119,7 @@ export const report = {
const { id } = await db.addReport({
reason: input.reason,
body: input.body,
createdAt: input.createdAt,
createdAt: input.createdAt ? new Date(input.createdAt) : null,
reporterTeamId: input.reporter,
reportedTeamId: input.reported
});
@ -29,6 +129,20 @@ export const report = {
};
}
}),
editReport: defineAction({
input: z.object({
reportId: z.number(),
reported: z.number().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
await db.editReport({
id: input.reportId,
reportedTeamId: input.reported
});
}
}),
reportStatus: defineAction({
input: z.object({
reportId: z.number()
@ -47,12 +161,40 @@ export const report = {
status: z.enum(['open', 'closed']).nullable(),
notice: z.string().nullable(),
statement: z.string().nullable(),
strikeId: z.number().nullable()
strikeReasonId: z.number().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
await db.editReportStatus(input);
let preReportStrike;
if (input.status === 'closed') preReportStrike = await db.getStrikeByReportId({ reportId: input.reportId });
await db.transaction(async (tx) => {
await tx.editReportStatus(input);
if (input.strikeReasonId) {
await db.editStrike({
reportId: input.reportId,
strikeReasonId: input.strikeReasonId
});
} else {
await db.deleteStrike({ reportId: input.reportId });
}
});
if (input.status === 'closed' && preReportStrike?.strikeReason?.id != input.strikeReasonId) {
const report = await db.getReportById({ id: input.reportId });
if (report.reported) {
const strikes = await db.getStrikesByTeamId({ teamId: report.reported.id });
const teamMembers = await db.getTeamMembersByTeamId({ teamId: report.reported.id });
// send webhook in background
sendWebhook(WebhookAction.Strike, {
users: teamMembers.map((tm) => tm.user.uuid!),
totalWeight: strikes.map((strike) => strike.reason.weight).reduce((a, b) => a + b, 0)
});
}
}
}
}),
reports: defineAction({
@ -68,6 +210,18 @@ export const report = {
};
}
}),
reportAttachments: defineAction({
input: z.object({
reportId: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
reportAttachments: (await db.getReportAttachments(input)) ?? []
};
}
}),
addStrikeReason: defineAction({
input: z.object({
name: z.string(),
@ -109,5 +263,29 @@ export const report = {
strikeReasons: await db.getStrikeReasons({})
};
}
}),
teamNamesByUsername: defineAction({
input: z.object({
username: z.string().nullish()
}),
handler: async (input) => {
const teams = await db.getTeamsByUsername({ username: input.username ?? '', limit: 5 });
return {
teamNames: teams.map((team) => ({ name: team.user.username, value: team.team.name }))
};
}
}),
teamNamesByTeamName: defineAction({
input: z.object({
teamName: z.string().nullish()
}),
handler: async (input) => {
const teams = await db.getTeams({ name: input.teamName, limit: 5 });
return {
teamNames: teams.map((team) => ({ name: team.name, value: team.name }))
};
}
})
};

View File

@ -7,18 +7,18 @@ import { getSetting, SettingKey } from '@util/settings.ts';
export const signup = {
signup: defineAction({
input: z.object({
firstname: z.string().min(2),
lastname: z.string().min(2),
// this will be inaccurate as it is evaluated only once
firstname: z.string().trim().min(2),
lastname: z.string().trim().min(2),
birthday: z
.string()
.date()
// this will be inaccurate as it is evaluated only once
.max(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
phone: z.string().nullable(),
username: z.string(),
phone: z.string().trim().nullable(),
username: z.string().trim(),
teamMember: z.string(),
teamName: z.string().nullable()
teamMember: z.string().trim(),
teamName: z.string().trim().nullable()
}),
handler: async (input) => {
// check if signup is allowed
@ -32,7 +32,7 @@ export const signup = {
// check if username and team member is equal
if (input.username.toLowerCase() === input.teamMember.toLowerCase()) {
throw new ActionError({
code: 'BAD_REQUEST',
code: 'CONFLICT',
message: 'Du kannst nicht mit dir selber in einem Team sein'
});
}
@ -66,6 +66,16 @@ export const signup = {
});
}
let memberUuid;
try {
memberUuid = await getJavaUuid(input.teamMember);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Der Minecraft Java Account deines Mitspieler mit dem Username ${input.teamMember} wurde nicht gefunden`
});
}
// check if user is blocked
if (uuid) {
const blockedUser = await db.getBlockedUserByUuid({ uuid: uuid });
@ -76,6 +86,15 @@ export const signup = {
});
}
}
if (memberUuid) {
const blockedUser = await db.getBlockedUserByUuid({ uuid: memberUuid });
if (blockedUser) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Dein Mitspieler ist für die Registrierung gesperrt. Bitte suche dir einen anderen Mitspieler'
});
}
}
if (!teamDraft) {
// check if a team with the same name already exists

View File

@ -128,5 +128,53 @@ export const team = {
teams: await db.getTeams(input)
};
}
}),
addDeath: defineAction({
input: z.object({
deadUserId: z.number(),
killerUserId: z.number().nullish(),
message: z.string()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
const { id } = await db.addDeath(input);
return {
id: id
};
}
}),
editDeath: defineAction({
input: z.object({
id: z.number(),
deadUserId: z.number(),
killerUserId: z.number().nullish(),
message: z.string()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.editDeath(input);
}
}),
deleteDeath: defineAction({
input: z.object({
id: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.deleteDeath(input);
}
}),
deaths: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
return {
deaths: await db.getDeaths({})
};
}
})
};

57
src/actions/tools.ts Normal file
View File

@ -0,0 +1,57 @@
import { ActionError, defineAction } from 'astro:actions';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { z } from 'astro:schema';
import { getBedrockUuid, getJavaUuid } from '@util/minecraft.ts';
export const tools = {
uuidFromUsername: defineAction({
input: z.object({
edition: z.enum(['java', 'bedrock']),
username: z.string()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Tools);
let uuid = null;
switch (input.edition) {
case 'java':
try {
uuid = await getJavaUuid(input.username);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Der Username ${input.username} existiert nicht`
});
}
if (uuid == null) {
throw new ActionError({
code: 'BAD_REQUEST',
message: `Während der Anfrage zur Mojang API ist ein Fehler aufgetreten`
});
}
break;
case 'bedrock':
try {
uuid = await getBedrockUuid(input.username);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Der Username ${input.username} existiert nicht`
});
}
if (uuid == null) {
throw new ActionError({
code: 'BAD_REQUEST',
message: `Während der Anfrage zum Username Resolver ist ein Fehler aufgetreten`
});
}
break;
}
return {
uuid: uuid
};
}
})
};

View File

@ -4,7 +4,6 @@
import { onMount } from 'svelte';
import DataTable from '@components/admin/table/DataTable.svelte';
// consts
// consts
const dateFormat = new Intl.DateTimeFormat('de-DE', {
year: 'numeric',

View File

@ -1,11 +1,15 @@
<script lang="ts">
import type { Report, ReportStatus, StrikeReasons } from './reports.ts';
import { editReport, getReportAttachments, type Report, type ReportStatus, type StrikeReasons } from './reports.ts';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import Select from '@components/input/Select.svelte';
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import Icon from '@iconify/svelte';
// html bindings
let previewDialogElem: HTMLDialogElement;
// types
interface Props {
@ -17,14 +21,20 @@
let { strikeReasons, report }: Props = $props();
// states
let reportedTeam = $state<{ id: number; name: string } | null>(report?.reported ?? null);
let status = $state<'open' | 'closed' | null>(null);
let notice = $state<string | null>(null);
let statement = $state<string | null>(null);
let strikeReason = $state<string | null>(String(report?.strike?.strikeReasonId ?? null));
let reportAttachments = $state<{ type: 'image' | 'video'; hash: string }[]>([]);
let previewReportAttachment = $state<{ type: 'image' | 'video'; hash: string } | null>(null);
// consts
const strikeReasonValues = strikeReasons.reduce(
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
{}
{ [null]: 'Kein Vergehen' }
);
// lifetime
@ -38,6 +48,14 @@
notice = reportStatus.notice;
statement = reportStatus.statement;
});
getReportAttachments(report).then((value) => {
if (value) reportAttachments = value;
});
});
$effect(() => {
if (previewReportAttachment) previewDialogElem.show();
});
// callbacks
@ -45,43 +63,104 @@
$confirmPopupState = {
title: 'Änderungen speichern?',
message: 'Sollen die Änderungen am Report gespeichert werden?',
onConfirm: async () =>
editReportStatus(report!, {
onConfirm: async () => {
if (reportedTeam?.id != report?.reported?.id) {
report!.reported = reportedTeam;
await editReport(report!);
}
await editReportStatus(report!, {
status: status,
notice: notice,
statement: statement,
strikeId: null
} as ReportStatus)
strikeReasonId: Number(strikeReason)
} as ReportStatus);
}
};
}
function onCopyPublicLink(urlHash: string) {
navigator.clipboard.writeText(`${document.baseURI}report/${urlHash}`);
document.activeElement?.blur();
}
</script>
<div
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-2"
hidden={report === null}
>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (report = null)}>✕</button>
<div class="absolute right-2 top-2">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-circle btn-ghost"><Icon icon="heroicons:share" /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="menu dropdown-content bg-base-100 rounded-box z-1 p-2 shadow-sm w-max">
<li><button onclick={() => onCopyPublicLink(report?.urlHash)}>Öffentlichen Report Link kopieren</button></li>
</ul>
</div>
<button class="btn btn-sm btn-circle btn-ghost" onclick={() => (report = null)}>✕</button>
</div>
<div class="w-[34rem]">
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
<TeamSearch value={report?.reported?.name} label="Reportetes Team" />
<Textarea bind:value={notice} label="Interne Notizen" rows={8} />
<TeamSearch value={report?.reported?.name} label="Reportetes Team" onSubmit={(team) => (reportedTeam = team)} />
<Textarea bind:value={notice} label="Interne Notizen" rows={10} />
</div>
<div class="divider divider-horizontal"></div>
<div class="w-full">
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} />
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={9} />
<fieldset class="fieldset">
<legend class="fieldset-legend">Anhänge</legend>
<div class="h-16.5 rounded border border-dashed flex">
{#each reportAttachments as reportAttachment (reportAttachment.hash)}
<div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="cursor-zoom-in" onclick={() => (previewReportAttachment = reportAttachment)}>
{#if reportAttachment.type === 'image'}
<img
src={location.pathname + '/attachment/' + reportAttachment.hash}
alt={reportAttachment.hash}
class="w-16 h-16"
/>
{:else if reportAttachment.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={location.pathname + '/attachment/' + reportAttachment.hash} class="w-16 h-16"></video>
{/if}
</div>
</div>
{/each}
</div>
</fieldset>
</div>
<div class="divider divider-horizontal"></div>
<div class="flex flex-col w-[42rem]">
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} />
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={7} />
<Select
bind:value={status}
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
defaultValue="Unbearbeitet"
label="Bearbeitungsstatus"
dynamicWidth
/>
<Select bind:value={status} values={strikeReasonValues} defaultValue="" label="Vergehen" dynamicWidth></Select>
<Select bind:value={strikeReason} values={strikeReasonValues} label="Vergehen" dynamicWidth></Select>
<div class="divider mt-0 mb-2"></div>
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
</div>
</div>
<dialog
class="modal"
bind:this={previewDialogElem}
onclose={() => setTimeout(() => (previewReportAttachment = null), 300)}
>
<div class="modal-box">
{#if previewReportAttachment?.type === 'image'}
<img src={location.pathname + '/attachment/' + previewReportAttachment.hash} alt={previewReportAttachment.hash} />
{:else if previewReportAttachment?.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={location.pathname + '/attachment/' + previewReportAttachment.hash} controls></video>
{/if}
</div>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="absolute top-3 right-3 btn btn-circle"></button>
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -4,6 +4,15 @@
import DataTable from '@components/admin/table/DataTable.svelte';
import { type StrikeReasons, getStrikeReasons, reports } from '@app/admin/reports/reports.ts';
// consts
const dateFormat = new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// states
let strikeReasons = $state<StrikeReasons>([]);
let activeReport = $state<Report | null>(null);
@ -14,6 +23,18 @@
});
</script>
{#snippet date(value: string)}
{dateFormat.format(new Date(value))}
{/snippet}
{#snippet status(value: null | 'open' | 'closed')}
{#if value === 'open'}
<p>In Bearbeitung</p>
{:else if value === 'closed'}
<p>Bearbeitet</p>
{/if}
{/snippet}
<DataTable
data={reports}
count={true}
@ -21,8 +42,8 @@
{ key: 'reason', label: 'Grund' },
{ key: 'reporter.name', label: 'Report Team' },
{ key: 'reported.name', label: 'Reportetes Team' },
{ key: 'createdAt', label: 'Datum' },
{ key: 'report.status?.status', label: 'Bearbeitungsstatus' }
{ key: 'createdAt', label: 'Datum', transform: date },
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
]}
onClick={(report) => (activeReport = report)}
/>

View File

@ -61,7 +61,7 @@
key: 'createdAt',
type: 'checkbox',
label: 'Report kann bearbeitet werden',
options: { convert: (v) => (v ? new Date().toISOString() : null) }
options: { convert: (v) => (v ? null : new Date().toISOString()) }
}
]
]}

View File

@ -9,7 +9,7 @@ export type Report = Reports[0];
export type ReportStatus = Exclude<
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
null
>;
> & { strikeReasonId: number | null };
export type StrikeReasons = Exclude<
ActionReturnType<typeof actions.report.strikeReasons>['data'],
@ -34,7 +34,7 @@ export async function addReport(report: Report) {
const { data, error } = await actions.report.addReport({
reason: report.reason,
body: report.body,
createdAt: report.createdAt,
createdAt: report.createdAt as unknown as string,
reporter: report.reporter.id,
reported: report.reported?.id ?? null
});
@ -59,13 +59,24 @@ export async function getReportStatus(report: Report) {
return data.reportStatus;
}
export async function editReport(report: Report) {
const { error } = await actions.report.editReport({
reportId: report.id,
reported: report.reported?.id ?? null
});
if (error) {
actionErrorPopup(error);
}
}
export async function editReportStatus(report: Report, reportStatus: ReportStatus) {
const { error } = await actions.report.editReportStatus({
reportId: report.id,
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement,
strikeId: reportStatus.strikeId
strikeReasonId: reportStatus.strikeReasonId
});
if (error) {
@ -73,6 +84,18 @@ export async function editReportStatus(report: Report, reportStatus: ReportStatu
}
}
export async function getReportAttachments(report: Report) {
const { data, error } = await actions.report.reportAttachments({
reportId: report.id
});
if (error) {
actionErrorPopup(error);
return;
}
return data.reportAttachments;
}
export async function getStrikeReasons() {
const { data, error } = await actions.report.strikeReasons();
if (error) {

View File

@ -52,6 +52,12 @@
value: dynamicSettings.signupEnabled(),
onChange: dynamicSettings.signupSetEnabled
},
{
name: 'Text unter dem Anmelde Button',
type: 'textarea',
value: dynamicSettings.signupInfoText(),
onChange: dynamicSettings.signupSetInfoText
},
{
name: 'Text, wenn die Anmeldung deaktiviert ist',
type: 'textarea',
@ -90,37 +96,39 @@
<div class="flex flex-col gap-5">
{#each setting.entries as entry (entry.name)}
<label class="flex justify-between">
<span class="mt-[.125rem] text-sm">{entry.name}</span>
{#if entry.type === 'checkbox'}
<input
type="checkbox"
class="toggle"
onchange={(e) => {
entry.onChange(e.currentTarget.checked);
changes = dynamicSettings.getChanges();
}}
checked={entry.value}
/>
{:else if entry.type === 'text'}
<input
type="text"
class="input input-bordered"
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
value={entry.value}
/>
{:else if entry.type === 'textarea'}
<textarea
class="textarea"
value={entry.value}
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
></textarea>
{/if}
<span class="mt-[.125rem] text-sm w-1/2">{entry.name}</span>
<div class="w-1/2">
{#if entry.type === 'checkbox'}
<input
type="checkbox"
class="toggle"
onchange={(e) => {
entry.onChange(e.currentTarget.checked);
changes = dynamicSettings.getChanges();
}}
checked={entry.value}
/>
{:else if entry.type === 'text'}
<input
type="text"
class="input input-bordered"
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
value={entry.value}
/>
{:else if entry.type === 'textarea'}
<textarea
class="textarea"
value={entry.value}
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
></textarea>
{/if}
</div>
</label>
{/each}
</div>

View File

@ -34,6 +34,10 @@ export class DynamicSettings {
signupEnabled = () => this.get(SettingKey.SignupEnabled, false);
signupSetEnabled = (active: boolean) => this.set(SettingKey.SignupEnabled, active);
/* signup info text */
signupInfoText = () => this.get(SettingKey.SignupInfoMessage, '');
signupSetInfoText = (text: string) => this.set(SettingKey.SignupInfoMessage, text);
/* signup disabled text */
signupDisabledText = () => this.get(SettingKey.SignupDisabledMessage, '');
signupSetDisabledText = (text: string) => this.set(SettingKey.SignupDisabledMessage, text);

View File

@ -33,7 +33,8 @@
count={true}
keys={[
{ key: 'name', label: 'Name', width: 20 },
{ key: 'weight', label: 'Gewichtung', width: 70, sortable: true }
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
{ key: 'id', label: 'Id', width: 20 }
]}
onDelete={onBlockedUserDelete}
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { addDeath, fetchDeaths } from '@app/admin/teamDeaths/teamDeaths.ts';
import Icon from '@iconify/svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
// states
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchDeaths();
});
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Spielertod</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Spielertod erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Spielertod erstellen?',
confirmPopupMessage: 'Soll der neue Spielertod erstellt werden?'
}}
target={null}
keys={[
[
{
key: 'killed',
type: 'user-search',
label: 'Getöteter Spieler',
options: { required: true, validate: (user) => !!user?.id }
},
{
key: 'killer',
type: 'user-search',
label: 'Killer',
options: { validate: (user) => (user?.username ? !!user?.id : true) }
}
],
[{ key: 'message', type: 'textarea', label: 'Todesnachricht', options: { required: true, dynamicWidth: true } }]
]}
onSubmit={addDeath}
bind:open={createPopupOpen}
/>

View File

@ -0,0 +1,69 @@
<script lang="ts">
// state
import { type Death, deaths, deleteDeath, editDeath } from '@app/admin/teamDeaths/teamDeaths.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import DataTable from '@components/admin/table/DataTable.svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
let editPopupDeath = $state(null);
let editPopupOpen = $derived(!!editPopupDeath);
// lifecycle
$effect(() => {
if (!editPopupOpen) editPopupDeath = null;
});
// callbacks
function onDeathDelete(death: Death) {
$confirmPopupState = {
title: 'Tod löschen?',
message: 'Soll der Tod wirklich gelöscht werden?',
onConfirm: () => deleteDeath(death)
};
}
</script>
{#snippet username(user?: { id: number; username: string })}
{user?.username}
{/snippet}
<DataTable
data={deaths}
count={true}
keys={[
{ key: 'killed', label: 'Getöteter Spieler', width: 20, transform: username },
{ key: 'killer', label: 'Killer', width: 20, transform: username },
{ key: 'message', label: 'Todesnachricht', width: 50 }
]}
onEdit={(death) => (editPopupDeath = death)}
onDelete={onDeathDelete}
/>
<CrudPopup
texts={{
title: 'Tod bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern?',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={editPopupDeath}
keys={[
[
{
key: 'killed',
type: 'user-search',
label: 'Getöteter Spieler',
options: { required: true, validate: (user) => !!user?.id }
},
{
key: 'killer',
type: 'user-search',
label: 'Killer',
options: { validate: (user) => (user?.username ? !!user?.id : true) }
}
],
[{ key: 'message', type: 'textarea', label: 'Todesnachricht', options: { required: true, dynamicWidth: true } }]
]}
onSubmit={editDeath}
bind:open={editPopupDeath}
/>

View File

@ -0,0 +1,61 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types
export type Deaths = Exclude<ActionReturnType<typeof actions.team.deaths>['data'], undefined>['deaths'];
export type Death = Deaths[0];
// state
export const deaths = writable<Deaths>([]);
// actions
export async function fetchDeaths() {
const { data, error } = await actions.team.deaths();
if (error) {
actionErrorPopup(error);
return;
}
deaths.set(data.deaths);
}
export async function addDeath(death: Death) {
const { data, error } = await actions.team.addDeath({
deadUserId: death.killed.id,
killerUserId: death.killer?.id,
message: death.message
});
if (error) {
actionErrorPopup(error);
return;
}
addToWritableArray(deaths, Object.assign(death, { id: data.id }));
}
export async function editDeath(death: Death) {
const { error } = await actions.team.editDeath({
id: death.id,
deadUserId: death.killed.id,
killerUserId: death.killer?.id,
message: death.message
});
if (error) {
actionErrorPopup(error);
return;
}
updateWritableArray(deaths, death, (d) => d.id == death.id);
}
export async function deleteDeath(death: Death) {
const { error } = await actions.team.deleteDeath({ id: death.id });
if (error) {
actionErrorPopup(error);
return;
}
deleteFromWritableArray(deaths, (d) => d.id == death.id);
}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { addTeam, deleteTeam, type Team, teams } from '@app/admin/teams/teams.ts';
import { deleteTeam, editTeam, type Team, teams } from '@app/admin/teams/teams.ts';
import DataTable from '@components/admin/table/DataTable.svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
@ -27,14 +27,18 @@
<div class="rounded-sm w-3 h-3" style="background-color: {value}"></div>
{/snippet}
{#snippet signedUp(value: { id?: number; username: string })}
<span class={{ 'text-base-content/50': value.id == null }}>{value.username}</span>
{/snippet}
<DataTable
data={teams}
count={true}
keys={[
{ key: 'color', label: 'Farbe', width: 5, transform: color },
{ key: 'name', label: 'Name', width: 25 },
{ key: 'memberOne.username', label: 'Spieler 1', width: 30 },
{ key: 'memberTwo.username', label: 'Spieler 2', width: 30 }
{ key: 'memberOne', label: 'Spieler 1', width: 30, transform: signedUp },
{ key: 'memberTwo', label: 'Spieler 2', width: 30, transform: signedUp }
]}
onEdit={(team) => (editPopupTeam = team)}
onDelete={onTeamDelete}
@ -78,6 +82,6 @@
}
]
]}
onSubmit={addTeam}
onSubmit={editTeam}
bind:open={editPopupOpen}
/>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import Select from '@components/input/Select.svelte';
import { uuidFromUsername } from '@app/admin/tools/tools.ts';
// states
let edition = $state<'java' | 'bedrock'>('java');
let username = $state('');
let uuid = $state<null | string>(null);
// callbacks
async function onSubmit() {
uuid = await uuidFromUsername(edition, username);
}
</script>
<fieldset class="fieldset border border-base-200 rounded-box px-4">
<legend class="fieldset-legend">Account UUID finder</legend>
<div>
<div class="flex gap-3">
<Input bind:value={username} />
<Select bind:value={edition} values={{ java: 'Java', bedrock: 'Bedrock' }} />
</div>
<div class="flex justify-center">
<button class="btn w-4/6" class:disabled={!username} onclick={onSubmit}>UUID finden</button>
</div>
<Input bind:value={uuid} readonly />
</div>
</fieldset>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import AccountUuidFinder from '@app/admin/tools/AccountUuidFinder.svelte';
</script>
<div class="flex justify-center mt-2">
<AccountUuidFinder />
</div>

View File

@ -0,0 +1,12 @@
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
export async function uuidFromUsername(edition: 'java' | 'bedrock', username: string) {
const { data, error } = await actions.tools.uuidFromUsername({ edition: edition, username: username });
if (error) {
actionErrorPopup(error);
return null;
}
return data.uuid;
}

View File

@ -35,7 +35,7 @@ export async function addUser(user: User) {
return;
}
addToWritableArray(users, user);
addToWritableArray(users, Object.assign(user, { id: data.id }));
}
export async function editUser(user: User) {

View File

@ -4,7 +4,7 @@
import MenuRules from '@assets/img/menu-rules.webp';
import MenuFaq from '@assets/img/menu-faq.webp';
import MenuFeedback from '@assets/img/menu-feedback.webp';
import MenuTeam from '@assets/img/menu-team.webp';
import MenuAdmins from '@assets/img/menu-admins.webp';
import MenuButton from '@assets/img/menu-button.webp';
import MenuInventoryBar from '@assets/img/menu-inventory-bar.webp';
import MenuSelectedFrame from '@assets/img/menu-selected-frame.webp';
@ -48,9 +48,9 @@
active: false
},
{
name: 'Team',
sprite: MenuTeam.src,
href: 'team',
name: 'Admins',
sprite: MenuAdmins.src,
href: 'admins',
active: false
}
]);

View File

@ -1,61 +1,146 @@
<script lang="ts">
import Steve from '@assets/img/steve.png';
import type { GetDeathsRes } from '@db/schema/death.ts';
import { type ActionReturnType, actions } from 'astro:actions';
import type { db } from '@db/database.ts';
import crown from '@assets/img/crown.svg';
interface Props {
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
deaths: GetDeathsRes;
deaths: Awaited<ReturnType<typeof db.getDeaths>>;
}
const { teams, deaths }: Props = $props();
const entries = teams.map((team) => ({
...team,
memberOne: Object.assign(team.memberOne, {
kills: deaths.filter((d) => d.killer?.id === team.memberOne.id) ?? [],
dead: deaths.find((d) => d.killed.id === team.memberOne.id) ?? null
}),
memberTwo: Object.assign(team.memberTwo, {
kills: deaths.filter((d) => d.killer?.id === team.memberTwo.id) ?? [],
dead: deaths.find((d) => d.killed.id === team.memberTwo.id) ?? null
})
}));
entries.sort((a, b) => {
const aBothSignedUp = a.memberOne.id != null && a.memberTwo.id != null;
const aBothKills = a.memberOne.kills.length + a.memberTwo.kills.length;
const aBothDead = a.memberOne.dead && a.memberTwo.dead;
const bBothSignedUp = b.memberOne.id != null && b.memberTwo.id != null;
const bBothKills = b.memberOne.kills.length + b.memberTwo.kills.length;
const bBothDead = b.memberOne.dead && b.memberTwo.dead;
if (!aBothSignedUp || !bBothSignedUp) {
return Number(bBothSignedUp) - Number(aBothSignedUp);
} else if ((aBothDead && !bBothDead) || (!aBothDead && bBothDead)) {
return Number(!!aBothDead) - Number(!!bBothDead);
}
return bBothKills - aBothKills;
});
const aliveTeams = entries.reduce(
(prev, curr) =>
prev + Number(curr.memberOne.id && curr.memberTwo.id && (!curr.memberOne.dead || !curr.memberTwo.dead)),
0
);
</script>
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
<table class="table table-fixed">
<thead>
<tr>
<th>Team</th>
<th>Spieler 1</th>
<th>Spieler 2</th>
<th>Kills</th>
<th style="width: 30%">Team</th>
<th style="width: 30%">Spieler 1</th>
<th style="width: 30%">Spieler 2</th>
<th style="width: 10%">Kills</th>
</tr>
</thead>
<tbody>
{#each teams as team (team.id)}
{#each entries as team (team.id)}
{@const teamSignedUp = !!team.memberOne.id && !!team.memberTwo.id}
{@const teamDead = !!team.memberOne.dead && !!team.memberTwo.dead}
<tr>
<td>
<div class="flex items-center gap-x-2">
<div class="rounded-sm w-3 h-3" style="background-color: {team.color}"></div>
<h3 class="text-xs sm:text-xl">{team.name}</h3>
<div class="relative">
<div class="rounded-sm min-w-3 w-3 min-h-3 h-3" style="background-color: {team.color}"></div>
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
<div class="absolute h-3.5 w-3.5 -top-2.25 -right-0.25">
<img class="h-full w-full" src={crown.src} alt="" />
</div>
{/if}
</div>
<h3 class="text-xs sm:text-xl break-all" class:line-through={teamDead}>
{team.name}
</h3>
</div>
{#if !teamSignedUp}
<span>Team unvollständig</span>
{/if}
</td>
<td class="max-w-9 overflow-ellipsis">
{#if team.memberOne.id}
<div class="flex items-center gap-x-2">
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
<span
class="text-xs sm:text-md"
class:line-through={deaths.find((d) => d.deadUserId === team.memberOne.id)}
>{team.memberOne.username}</span
>
</div>
{/if}
<div class="flex items-center gap-x-2 w-max tooltip">
{#if team.memberOne.kills.length > 0 || team.memberOne.dead}
<div class="tooltip-content text-left space-y-1">
{#each team.memberOne.kills as kill (kill.killed.id)}
<p>🔪 {kill.killed.username}</p>
{/each}
{#if team.memberOne.dead}
<p class="mt-2 first:mt-0">{team.memberOne.dead.message}</p>
{/if}
</div>
{/if}
{#if team.memberOne.id != null}
<div class="relative">
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberOne.username}/8" alt="head" />
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
<div class="absolute -top-1.25 -right-1.25">
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
</div>
{/if}
</div>
{/if}
<span
class="text-xs sm:text-md break-all"
class:line-through={team.memberOne.dead}
class:text-gray-500={team.memberOne.id == null}>{team.memberOne.username}</span
>
</div>
</td>
<td>
{#if team.memberTwo.id}
<div class="flex items-center gap-x-2">
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
<span
class="text-xs sm:text-md"
class:line-through={deaths.find((d) => d.deadUserId === team.memberTwo.id)}
>{team.memberTwo.username}</span
>
</div>
{/if}
<div class="flex items-center gap-x-2 w-max tooltip">
{#if team.memberTwo.kills.length > 0 || team.memberTwo.dead}
<div class="tooltip-content text-left space-y-1">
{#each team.memberTwo.kills as kill (kill.killed.id)}
<p>🔪 {kill.killed.username}</p>
{/each}
{#if team.memberTwo.dead}
<p class="mt-2 first:mt-0">{team.memberTwo.dead.message}</p>
{/if}
</div>
{/if}
{#if team.memberTwo.id != null}
<div class="relative">
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberTwo.username}/8" alt="head" />
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
<div class="absolute -top-1.25 -right-1.25">
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
</div>
{/if}
</div>
{/if}
<span
class="text-xs sm:text-md break-all"
class:line-through={team.memberTwo.dead}
class:text-gray-500={team.memberTwo.id == null}>{team.memberTwo.username}</span
>
</div>
</td>
<td>
<span class="text-xs sm:text-md">0</span>
<span class="text-xs sm:text-md">
{team.memberOne.kills.length + team.memberTwo.kills.length}
</span>
</td>
</tr>
{/each}

View File

@ -0,0 +1,77 @@
<script lang="ts">
import Select from '@components/input/Select.svelte';
import Search from '@components/admin/search/Search.svelte';
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
// types
interface Props {
adversary: { type: 'user'; name: string | null } | { type: 'team'; name: string | null } | { type: 'unknown' };
}
// input
const { adversary = $bindable() }: Props = $props();
// states
let adversaryTeamName = $state(adversary.type == 'team' ? adversary.name : null);
// lifecycle
$effect(() => {
document.dispatchEvent(
new CustomEvent('adversaryInput', {
detail: {
adversaryTeamName: adversaryTeamName
}
})
);
});
// functions
async function getSuggestions(query: string) {
if (adversary.type == 'user') {
const { data, error } = await actions.report.teamNamesByUsername({ username: query });
if (error) {
actionErrorPopup(error);
return [];
}
return data.teamNames;
} else if (adversary.type == 'team') {
const { data, error } = await actions.report.teamNamesByTeamName({ teamName: query });
if (error) {
actionErrorPopup(error);
return [];
}
return data.teamNames;
} else {
return [];
}
}
</script>
<div class="flex flex-row space-x-4">
<div class="w-1/3">
<Select
bind:value={adversary.type}
values={{
unknown: 'Ich möchte einen unbekannten Spieler / ein unbekanntes Team reporten',
user: 'Ich möchte einen Spieler reporten',
team: 'Ich möchte ein Team reporten'
}}
dynamicWidth
/>
</div>
{#if adversary.type === 'user' || adversary.type === 'team'}
<Search
value={adversary.name}
requestSuggestions={getSuggestions}
onSubmit={(value) => (adversaryTeamName = value != null ? value.value : null)}
/>
{/if}
</div>
<span class="text-base-content/60 text-xs -mt-4" class:hidden={adversary.type !== 'user'}
>Reports von Spielern werden immer auf das ganze Team übertragen</span
>

View File

@ -0,0 +1,178 @@
<script lang="ts">
import { popupState } from '@components/popup/Popup.ts';
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
// bindings
let hiddenFileInputElem: HTMLInputElement;
let previewDialogElem: HTMLDialogElement;
// types
interface Props {
maxFilesBytes: number;
}
interface UploadFile {
dataUrl: string;
name: string;
type: 'image' | 'video';
size: number;
file: File;
}
// inputs
const { maxFilesBytes }: Props = $props();
// states
let uploadFiles = $state<UploadFile[]>([]);
let previewUploadFile = $state<UploadFile | null>(null);
// lifecycle
$effect(() => {
document.dispatchEvent(
new CustomEvent('dropzoneInput', {
detail: {
files: uploadFiles.map((uf) => uf.file)
}
})
);
});
$effect(() => {
if (previewUploadFile) previewDialogElem.show();
});
// functions
function addFiles(files: FileList) {
for (const file of files) {
if (uploadFiles.find((uf) => uf.name === file.name && uf.size === file.size) !== undefined) {
continue;
}
let type: 'image' | 'video';
if (allowedImageTypes.find((mime) => mime === file.type) !== undefined) {
type = 'image';
} else if (allowedVideoTypes.find((mime) => mime === file.type) !== undefined) {
type = 'video';
} else {
$popupState = {
type: 'error',
title: 'Ungültige Datei',
message:
'Das Dateiformat wird nicht unterstützt. Nur Bilder (.png, .jpg, .jpeg, .webp, .avif) und Videos (.mp4, .webm) sind gültig'
};
return;
}
if (uploadFiles.reduce((prev, curr) => prev + curr.size, 0) + file.size > maxFilesBytes) {
$popupState = {
type: 'error',
title: 'Datei zu groß',
message: `Die Dateien dürfen insgesamt nur ${bytesToHumanReadable(maxFilesBytes)} groß sein. Fall deine Anhänge größer sind, lade sie bitte auf einem externen Filehoster hoch (z.B. file.io, Google Drive, ...) und füge den Link zum teilen der Datei(en) zu den Report Details hinzu`
};
return;
}
const reader = new FileReader();
reader.onload = () => {
uploadFiles.push({
dataUrl: reader.result as string,
name: file.name,
type: type,
size: file.size,
file: file
});
};
reader.readAsDataURL(file);
}
}
function removeFile(file: UploadFile) {
const index = uploadFiles.findIndex((uf) => uf.size === file.size && uf.name === file.name);
const uploadFile = uploadFiles.splice(index, 1).pop()!;
URL.revokeObjectURL(uploadFile.dataUrl);
}
function bytesToHumanReadable(bytes: number) {
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = parseFloat((bytes / Math.pow(1024, i)).toFixed(2));
return `${size} ${sizes[i]}`;
}
// callbacks
function onAddFiles(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
e.preventDefault();
if ((e.target as typeof e.currentTarget).files) addFiles((e.target as typeof e.currentTarget).files!);
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) addFiles(e.dataTransfer.files);
}
function onFileRemove(file: UploadFile) {
removeFile(file);
}
</script>
<fieldset class="fieldset">
<legend class="fieldset-legend">Anhänge</legend>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="h-12 rounded border border-dashed flex cursor-pointer"
class:h-26={uploadFiles.length > 0}
dropzone="copy"
onclick={() => hiddenFileInputElem.click()}
ondrop={onDrop}
ondragover={(e) => e.preventDefault()}
>
{#if uploadFiles.length === 0}
<div class="flex justify-center items-center w-full h-full">
<p>Hier Dateien droppen oder klicken um sie hochzuladen</p>
</div>
{/if}
{#each uploadFiles as uploadFile (uploadFile.name)}
<div
class="relative flex flex-col items-center w-22 h-22 m-1 cursor-default"
onclick={(e) => e.stopImmediatePropagation()}
>
<div class="cursor-zoom-in" onclick={() => (previewUploadFile = uploadFile)}>
{#if uploadFile.type === 'image'}
<img src={uploadFile.dataUrl} alt={uploadFile.name} class="w-16 h-16" />
{:else if uploadFile.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={uploadFile.dataUrl} class="w-16 h-16"></video>
{/if}
</div>
<span>{bytesToHumanReadable(uploadFile.size)}</span>
<button class="cursor-pointer" onclick={() => onFileRemove(uploadFile)}>Datei entfernen</button>
</div>
{/each}
</div>
</fieldset>
<input
bind:this={hiddenFileInputElem}
type="file"
multiple
accept={[...allowedImageTypes, ...allowedVideoTypes].join(', ')}
class="hidden absolute top-0 left-0 h-0 w-0"
onchange={onAddFiles}
/>
<dialog class="modal" bind:this={previewDialogElem} onclose={() => setTimeout(() => (previewUploadFile = null), 300)}>
<div class="modal-box">
{#if previewUploadFile?.type === 'image'}
<img src={previewUploadFile.dataUrl} alt={previewUploadFile.name} />
{:else if previewUploadFile?.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={previewUploadFile.dataUrl} controls></video>
{/if}
</div>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="absolute top-3 right-3 btn btn-circle"></button>
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { registeredPopupState } from '@app/website/signup/RegisteredPopup.ts';
import Input from '@components/input/Input.svelte';
import { onDestroy } from 'svelte';
interface Props {
discordLink: string;
@ -15,7 +16,7 @@
let modal: HTMLDialogElement;
registeredPopupState.subscribe(async (value) => {
const cancel = registeredPopupState.subscribe(async (value) => {
if (!value) return;
modal.show();
@ -40,16 +41,18 @@
skinViewer.dispose();
});
onDestroy(cancel);
</script>
<dialog class="modal" bind:this={modal} onclose={($registeredPopupState = null)}>
<dialog class="modal" bind:this={modal} onclose={() => ($registeredPopupState = null)}>
<form method="dialog" class="modal-box xl:w-5/12 max-w-10/12 z-10">
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
<p class="text-center font-bold">
<span>Du hast Dich erfolgreich mit dem Team&nbsp;&nbsp;</span>
<span class="inline-flex rounded-sm w-3 h-3" style="background-color: {$registeredPopupState?.teamColor}"></span>
<span>{$registeredPopupState?.team}</span>
<span>&nbsp;&nbsp;für Varo 4 registriert</span>. Spielstart ist am
<span>&nbsp;&nbsp;für Varo 5 registriert</span>. Spielstart ist am
<i>
{new Date(startDate).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}
</i>
@ -74,13 +77,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
<Input type="text" value={$registeredPopupState?.firstname} label="Vorname" disabled />
<Input type="text" value={$registeredPopupState?.lastname} label="Nachname" disabled />
<Input
type="date"
value={$registeredPopupState?.birthday.toISOString().substring(0, 10)}
label="Geburtstag"
size="sm"
disabled
/>
<Input type="date" value={$registeredPopupState?.birthday} label="Geburtstag" size="sm" disabled />
<Input type="tel" value={$registeredPopupState?.phone} label="Telefonnummer" disabled />
<Input type="text" value={$registeredPopupState?.username} label="Spielername" disabled />
<Input type="text" value={$registeredPopupState?.teamMember} label="Mitspieler" disabled />

View File

@ -3,7 +3,7 @@ import { atom } from 'nanostores';
export const registeredPopupState = atom<{
firstname: string;
lastname: string;
birthday: Date;
birthday: string;
phone: string;
username: string;
team: string;

View File

@ -1,26 +1,29 @@
<script lang="ts">
import { rulesPopupState, rulesPopupRead } from './RulesPopup.ts';
import { rules } from '../../../rules.ts';
import { popupState } from '@components/popup/Popup.ts';
import { onDestroy } from 'svelte';
const modalTimeoutSeconds = 30;
let modalElem: HTMLDialogElement;
let modalTimer = $state(null);
let modalTimer = $state<ReturnType<typeof setInterval> | null>(null);
let modalSecondsOpen = $state(import.meta.env.PROD ? 0 : modalTimeoutSeconds);
rulesPopupState.listen((value) => {
const cancel = rulesPopupState.subscribe((value) => {
if (value == 'open') {
modalElem.show();
setInterval(() => modalSecondsOpen++, 1000);
modalTimer = setInterval(() => modalSecondsOpen++, 1000);
} else if (value == 'closed') {
clearInterval(modalTimer!);
}
});
onDestroy(cancel);
</script>
<dialog
id="rules-popup"
class="modal"
onclose={() => {
if ($rulesPopupState !== 'accepted') $rulesPopupState = 'closed';
@ -60,19 +63,29 @@
<div
class="relative w-min"
title={modalSecondsOpen < modalTimeoutSeconds
? `Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
? `Die Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
: ''}
>
<!--div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
<div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
<div
style="width: {Math.min((modalSecondsOpen / modalTimeoutSeconds) * 100, 100)}%"
class="h-full bg-base-300"
class="h-full bg-neutral"
></div>
</div-->
</div>
<button
class="btn btn-neutral"
disabled={modalSecondsOpen < modalTimeoutSeconds}
onclick={() => {
class="btn bg-transparent z-[1] relative"
class:btn-active={modalSecondsOpen < modalTimeoutSeconds}
class:cursor-default={modalSecondsOpen < modalTimeoutSeconds}
onclick={(e) => {
if (modalSecondsOpen < modalTimeoutSeconds) {
e.preventDefault();
$popupState = {
type: 'info',
title: 'Regeln',
message: 'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.'
};
return;
}
$rulesPopupRead = true;
$rulesPopupState = 'accepted';
}}>Akzeptieren</button

View File

@ -1,13 +1,17 @@
<script lang="ts">
import { teamPopupOpen, teamPopupName } from '@app/website/signup/TeamPopup.ts';
import Input from '@components/input/Input.svelte';
import { onDestroy } from 'svelte';
let modal: HTMLDialogElement;
let form: HTMLFormElement;
teamPopupOpen.subscribe((value) => {
const cancel = teamPopupOpen.subscribe((value) => {
if (value) modal.show();
else form?.reset();
});
onDestroy(cancel);
</script>
<dialog class="modal" bind:this={modal} onclose={() => ($teamPopupOpen = false)}>
@ -17,13 +21,17 @@
</form>
<form method="dialog" bind:this={form} onsubmit={() => ($teamPopupName = form.teamName.value)}>
<h3 class="text-lg font-geist">Team erstellen</h3>
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt.</p>
<fieldset class="fieldset">
<legend class="fieldset-legend">
<span>Teamname <span class="text-red-700">*</span></span>
</legend>
<input id="teamName" name="teamName" class="input validator" type="text" required />
</fieldset>
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt. Wie soll euer Team heißen?</p>
<Input id="teamName" type="text" label="Teamname" required>
{#snippet notice()}
<span>
Dein Team ist erst vollständig registriert, wenn dein Teamparter sich ebenfalls angemeldet hat. Eine
Teilnahme ohne Teampartner ist nicht möglich.
<br />
Prüfe bitte nach, ob dein Team auf der Startseite aufgelistet wird, sobald beide Teammitglieder registriert sind!
</span>
{/snippet}
</Input>
<button class="mt-4 btn btn-neutral">Team registrieren</button>
</form>
</div>

1
src/assets/img/crown.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><g fill="#f79329"><path d="m91.56 50.38l14.35 44.94l-36.36-4.71z"/><path d="M105.91 96.5c-.05 0-.1 0-.15-.01l-36.37-4.71c-.39-.05-.72-.29-.9-.64s-.17-.76.01-1.1l22.02-40.23c.23-.41.69-.65 1.15-.61c.47.04.87.36 1.01.81l14.24 44.62c.14.19.22.43.22.68c0 .65-.53 1.18-1.18 1.18c0 .01-.03.01-.05.01M71.4 89.66l32.82 4.25l-12.94-40.55zM40.19 34.91a5.46 5.46 0 0 1-5.46 5.46c-3.01 0-5.46-2.45-5.46-5.46c0-3.02 2.44-5.46 5.46-5.46s5.46 2.44 5.46 5.46"/><path d="M34.73 41.54a6.65 6.65 0 0 1-6.64-6.64a6.65 6.65 0 0 1 6.64-6.64a6.65 6.65 0 0 1 6.64 6.64a6.65 6.65 0 0 1-6.64 6.64m0-10.91c-2.36 0-4.28 1.92-4.28 4.28s1.92 4.28 4.28 4.28s4.29-1.92 4.29-4.28s-1.93-4.28-4.29-4.28m58.85-1.18c3.01.18 5.31 2.77 5.13 5.78c-.17 3.01-2.77 5.3-5.77 5.13a5.45 5.45 0 0 1-5.13-5.77c.18-3.02 2.76-5.32 5.77-5.14"/><path d="m93.26 41.54l-.39-.01c-1.77-.1-3.4-.89-4.57-2.21a6.62 6.62 0 0 1-1.67-4.8a6.647 6.647 0 0 1 6.63-6.25l.39.01c3.66.22 6.46 3.38 6.24 7.03a6.64 6.64 0 0 1-6.63 6.23m.23-10.92c-2.5 0-4.37 1.77-4.5 4.03c-.07 1.14.31 2.24 1.07 3.1s1.8 1.36 2.95 1.43l.25.01c2.26 0 4.14-1.77 4.27-4.03c.14-2.36-1.67-4.39-4.03-4.54zM36.43 50.38L22.09 95.32l36.36-4.71z"/><path d="M22.09 96.5c-.34 0-.68-.15-.91-.42c-.26-.31-.34-.73-.22-1.11L35.3 50.03c.14-.45.54-.77 1.01-.81c.51-.05.92.19 1.15.61l22.02 40.23c.18.34.19.75.01 1.1c-.17.35-.51.58-.9.64l-36.36 4.71c-.04-.01-.09-.01-.14-.01m14.63-43.14L23.77 93.92l32.82-4.25z"/></g><use href="#notoV1Crown1"/><use href="#notoV1Crown1"/><defs><path id="notoV1Crown0" d="M119.5 53.43a1.18 1.18 0 0 0-1.29.22L87.25 82.71L65.16 49.72c-.22-.33-.58-.52-.98-.52c-.39 0-.76.19-.98.51l-22.19 33l-30.95-29.07a1.18 1.18 0 0 0-1.29-.22c-.43.19-.71.63-.69 1.1l1.27 47.52c0 10.33 24.06 18.43 54.78 18.43s54.78-8.1 54.78-18.4l1.27-47.55c.02-.46-.25-.9-.68-1.09"/><path id="notoV1Crown1" fill="#fcc21b" d="M72.17 28.76c0 4.51-3.66 8.17-8.17 8.17s-8.18-3.66-8.18-8.17c0-4.52 3.66-8.17 8.18-8.17s8.17 3.65 8.17 8.17m-58.72 6.15c0 3.58-2.9 6.48-6.49 6.48c-3.58 0-6.48-2.9-6.48-6.48c0-3.59 2.9-6.49 6.48-6.49c3.59 0 6.49 2.9 6.49 6.49m101.09 0c0 3.58 2.9 6.48 6.49 6.48c3.58 0 6.49-2.9 6.49-6.48a6.49 6.49 0 0 0-6.49-6.49a6.49 6.49 0 0 0-6.49 6.49"/></defs><use fill="#fcc21b" href="#notoV1Crown0"/><clipPath id="notoV1Crown2"><use href="#notoV1Crown0"/></clipPath><path fill="#d7598b" d="m119.91 78.06l.01.01l-.59 18.85h-.01c-4.2-.13-7.46-4.45-7.3-9.66c.16-5.22 3.69-9.33 7.89-9.2m-111.54 0l-.01.01l.58 18.85h.02c4.19-.13 7.46-4.45 7.29-9.66c-.16-5.22-3.69-9.33-7.88-9.2" clip-path="url(#notoV1Crown2)"/><path fill="#d7598b" d="M72.8 96.55c0 5.58-3.88 10.11-8.67 10.11c-4.78 0-8.66-4.53-8.66-10.11c0-5.59 3.88-10.11 8.66-10.11c4.79-.01 8.67 4.52 8.67 10.11"/><g fill="#ed6c30"><path d="M89.9 102.14c-.13 2.7-2.12 4.79-4.44 4.68c-2.31-.11-4.08-2.4-3.94-5.09c.14-2.71 2.13-4.8 4.44-4.68c2.31.1 4.07 2.39 3.94 5.09"/><ellipse cx="103.04" cy="98.95" rx="4.89" ry="4.2" transform="rotate(-87.013 103.044 98.958)"/></g><g fill="#ed6c30"><path d="M38.37 102.14c.13 2.7 2.12 4.79 4.44 4.68c2.31-.11 4.08-2.4 3.94-5.09c-.13-2.71-2.12-4.8-4.43-4.68c-2.32.1-4.09 2.39-3.95 5.09"/><ellipse cx="25.23" cy="98.95" rx="4.19" ry="4.89" transform="rotate(-2.987 25.234 98.957)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

@ -111,11 +111,11 @@
submitEnabled = false;
for (const key of keys) {
for (const k of key) {
if (k.options?.required) {
if (k.options?.validate) {
if (k.options?.validate) {
if (k.options?.required && !target[k.key]) {
return;
} else if (k.options?.required || target[k.key]) {
if (!k.options.validate(target[k.key])) return;
} else {
if (!target[k.key]) return;
}
}
}

View File

@ -8,9 +8,9 @@
required?: boolean;
mustMatch?: boolean;
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
requestSuggestions: (query: string, limit: number) => Promise<{ name: string; value: string }[]>;
onSubmit?: (value: string | null) => void;
onSubmit?: (value: { name: string; value: string } | null) => void;
}
// html bindings
@ -21,7 +21,7 @@
// states
let inputValue = $derived(value);
let suggestions = $state<string[]>([]);
let suggestions = $state<{ name: string; value: string }[]>([]);
let matched = $state(false);
// callbacks
@ -34,9 +34,9 @@
suggestions = await requestSuggestions(inputValue ?? '', 5);
let suggestion = suggestions.find((s) => s === inputValue);
let suggestion = suggestions.find((s) => s.name === inputValue);
if (suggestion != null) {
inputValue = value = suggestion;
inputValue = value = suggestion.name;
matched = true;
onSubmit?.(suggestion);
} else if (!mustMatch) {
@ -49,8 +49,10 @@
}
}
function onSuggestionClick(suggestion: string) {
inputValue = value = suggestion;
function onSuggestionClick(name: string) {
const suggestion = suggestions.find((s) => s.name === name)!;
inputValue = value = suggestion.name;
suggestions = [];
onSubmit?.(suggestion);
}
@ -77,7 +79,7 @@
bind:value={inputValue}
oninput={() => onSearchInput()}
onfocusin={() => onSearchInput()}
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
pattern={mustMatch && matched ? `^(${suggestions.map((s) => s.name).join('|')})$` : undefined}
/>
{#if suggestions.length > 0}
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
@ -85,8 +87,8 @@
<li class="w-full text-left">
<button
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title={suggestion}
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
title={suggestion.name}
onclick={() => onSuggestionClick(suggestion.name)}>{suggestion.name}</button
>
</li>
{/each}

View File

@ -20,9 +20,6 @@
// inputs
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
// states
let teamSuggestionCache = $state<Teams>([]);
// functions
async function getSuggestions(query: string, limit: number) {
const { data, error } = await actions.team.teams({
@ -35,17 +32,7 @@
return [];
}
teamSuggestionCache = data.teams;
return teamSuggestionCache.map((team) => team.name);
}
async function getTeamByTeamName(teamName: string) {
let team = teamSuggestionCache.find((team) => team.name === teamName);
if (!team) {
await getSuggestions(teamName, 5);
return await getTeamByTeamName(teamName);
}
return team;
return data.teams.map((team) => ({ name: team.name, value: team }));
}
</script>
@ -57,5 +44,5 @@
{required}
{mustMatch}
requestSuggestions={async (teamName) => getSuggestions(teamName, 5)}
onSubmit={async (teamName) => onSubmit?.(teamName != null ? await getTeamByTeamName(teamName) : null)}
onSubmit={async (suggestion) => onSubmit?.(suggestion != null ? suggestion.value : null)}
/>

View File

@ -20,9 +20,6 @@
// inputs
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
// states
let userSuggestionCache = $state<Users>([]);
// functions
async function getSuggestions(query: string, limit: number) {
const { data, error } = await actions.user.users({
@ -35,17 +32,7 @@
return [];
}
userSuggestionCache = data.users;
return userSuggestionCache.map((user) => user.username);
}
async function getUserByUsername(username: string) {
let user = userSuggestionCache.find((user) => user.username === username);
if (!user) {
await getSuggestions(username, 5);
return await getUserByUsername(username);
}
return user;
return data.users.map((user) => ({ name: user.username, value: user }));
}
</script>
@ -57,5 +44,5 @@
{required}
{mustMatch}
requestSuggestions={async (username) => getSuggestions(username, 5)}
onSubmit={async (username) => onSubmit?.(username != null ? await getUserByUsername(username) : null)}
onSubmit={async (suggestion) => onSubmit?.(suggestion != null ? suggestion.value : null)}
/>

View File

@ -9,20 +9,17 @@
// inputs
let { available, value = $bindable(), readonly }: Props = $props();
// idk why, but this is needed to trigger loop reactivity
let reactiveValue = $derived(value);
// callbacks
function onOptionSelect(e: Event) {
const selected = Number((e.target as HTMLSelectElement).value);
reactiveValue |= selected;
value |= selected;
(e.target as HTMLSelectElement).value = '-';
}
function onBadgeRemove(flag: number) {
reactiveValue &= ~flag;
value &= ~flag;
}
</script>
@ -31,20 +28,22 @@
<select class="select select-xs w-min" onchange={onOptionSelect}>
<option selected hidden>-</option>
{#each Object.entries(available) as [flag, badge] (flag)}
<option value={flag} hidden={(reactiveValue & Number(flag)) !== 0}>{badge}</option>
<option value={flag} hidden={(value & Number(flag)) !== 0}>{badge}</option>
{/each}
</select>
{/if}
<div class="flex flow flex-wrap gap-2">
{#each Object.entries(available) as [flag, badge] (flag)}
{#if (reactiveValue & Number(flag)) !== 0}
<div class="badge badge-outline gap-1">
{#if !readonly}
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
{/if}
<span>{badge}</span>
</div>
{/if}
{/each}
{#key value}
{#each Object.entries(available) as [flag, badge] (flag)}
{#if (value & Number(flag)) !== 0}
<div class="badge badge-outline gap-1">
{#if !readonly}
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
{/if}
<span>{badge}</span>
</div>
{/if}
{/each}
{/key}
</div>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// types
interface Props {
id?: string;
value?: string | null;
@ -19,6 +20,7 @@
notice?: Snippet;
}
// inputs
let {
id,
value = $bindable(),
@ -56,8 +58,8 @@
{#if defaultValue != null}
<option disabled selected>{defaultValue}</option>
{/if}
{#each Object.entries(values) as [value, label] (value)}
<option {value}>{label}</option>
{#each Object.entries(values) as [v, label] (v)}
<option value={v} selected={v === value}>{label}</option>
{/each}
</select>
<p class="fieldset-label">

View File

@ -14,6 +14,7 @@
// callbacks
function onModalClose() {
$popupState?.onClose?.();
setTimeout(() => ($popupState = null), 300);
}
</script>

View File

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

View File

@ -61,13 +61,47 @@ CREATE TABLE IF NOT EXISTS team_draft (
-- death
CREATE TABLE IF NOT EXISTS death (
id INT AUTO_INCREMENT PRIMARY KEY,
message VARCHAR(1024) NOT NULL,
dead_user_id INT NOT NULL,
dead_user_id INT NOT NULL UNIQUE,
killer_user_id INT,
FOREIGN KEY (dead_user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (killer_user_id) REFERENCES user(id) ON DELETE CASCADE
);
-- report
CREATE TABLE IF NOT EXISTS report (
id INT AUTO_INCREMENT PRIMARY KEY,
reason VARCHAR(255) NOT NULL,
body TEXT,
url_hash VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP,
reporter_team_id INT,
reported_team_id INT,
FOREIGN KEY (reporter_team_id) REFERENCES team(id) ON DELETE CASCADE,
FOREIGN KEY (reported_team_id) REFERENCES team(id) ON DELETE CASCADE
);
-- report attachment
CREATE TABLE IF NOT EXISTS report_attachment (
id INT AUTO_INCREMENT PRIMARY KEY,
type ENUM('image', 'video') NOT NULL,
hash CHAR(32) NOT NULL,
report_id INT NOT NULL,
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE
);
-- report status
CREATE TABLE IF NOT EXISTS report_status (
status ENUM('open', 'closed'),
notice TEXT,
statement TEXT,
report_id INT NOT NULL UNIQUE,
reviewer_id INT,
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
FOREIGN KEY (reviewer_id) REFERENCES admin(id) ON DELETE CASCADE
);
-- strike reason
CREATE TABLE IF NOT EXISTS strike_reason (
id INT AUTO_INCREMENT PRIMARY KEY,
@ -77,36 +111,11 @@ CREATE TABLE IF NOT EXISTS strike_reason (
-- strike
CREATE TABLE IF NOT EXISTS strike (
id INT AUTO_INCREMENT PRIMARY KEY,
at TIMESTAMP NOT NULL,
strike_reason_id INT NOT NULL,
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
);
-- report
CREATE TABLE IF NOT EXISTS report (
id INT AUTO_INCREMENT PRIMARY KEY,
reason VARCHAR(255) NOT NULL,
body TEXT,
url_hash VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP,
reporter_team_id INT NOT NULL,
reported_team_id INT,
FOREIGN KEY (reporter_team_id) REFERENCES team(id) ON DELETE CASCADE,
FOREIGN KEY (reported_team_id) REFERENCES team(id) ON DELETE CASCADE
);
-- report status
CREATE TABLE IF NOT EXISTS report_status (
status ENUM('open', 'closed'),
notice TEXT,
statement TEXT,
report_id INT NOT NULL UNIQUE,
reviewer_id INT,
strike_id INT,
strike_reason_id INT NOT NULL,
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
FOREIGN KEY (reviewer_id) REFERENCES admin(id) ON DELETE CASCADE,
FOREIGN KEY (strike_id) REFERENCES strike(id) ON DELETE CASCADE
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
);
-- feedback
@ -123,6 +132,6 @@ CREATE TABLE IF NOT EXISTS feedback (
-- settings
CREATE TABLE IF NOT EXISTS settings (
name VARCHAR(255) NOT NULL,
name VARCHAR(255) UNIQUE NOT NULL,
value TEXT NOT NULL
);

View File

@ -30,7 +30,9 @@ import {
type EditTeamReq,
editTeam,
type GetTeamsFullReq,
getTeamsFull
getTeamsFull,
type GetTeamsByUsernameReq,
getTeamsByUsername
} from './schema/team';
import {
addTeamDraft,
@ -48,6 +50,8 @@ import {
type AddTeamMemberReq,
deleteTeamMemberByTeamId,
type DeleteTeamMemberByTeamIdReq,
getTeamMembersByTeamId,
type GetTeamMembersByTeamIdReq,
teamMember
} from './schema/teamMember';
import {
@ -79,9 +83,13 @@ import {
setSettings
} from './schema/settings';
import {
addDeath,
type AddDeathReq,
addDeath,
death,
deleteDeath,
type DeleteDeathReq,
editDeath,
type EditDeathReq,
getDeathByUserId,
type GetDeathByUserIdReq,
getDeaths,
@ -93,10 +101,28 @@ import {
addUserFeedbacks,
type AddUserFeedbacksReq,
feedback,
getFeedbackByUrlHash,
type GetFeedbackByUrlHash,
getFeedbacks,
type GetFeedbacksReq
type GetFeedbacksReq,
submitFeedback,
type SubmitFeedbackReq
} from './schema/feedback.ts';
import { addReport, type AddReportReq, getReports, type GetReportsReq, report } from './schema/report.ts';
import {
addReport,
type AddReportReq,
editReport,
type EditReportReq,
getReportById,
type GetReportById,
getReportByUrlHash,
type GetReportByUrlHash,
getReports,
type GetReportsReq,
report,
submitReport,
type SubmitReportReq
} from './schema/report.ts';
import { DATABASE_URI } from 'astro:env/server';
import {
type GetStrikeReasonsReq,
@ -109,7 +135,17 @@ import {
type DeleteStrikeReasonReq,
deleteStrikeReason
} from '@db/schema/strikeReason.ts';
import { getStrikesByTeamId, type GetStrikesByTeamId, strike } from '@db/schema/strike.ts';
import {
deleteStrike,
type DeleteStrikeReq,
editStrike,
type EditStrikeReq,
getStrikeByReportId,
type GetStrikeByReportIdReq,
getStrikesByTeamId,
type GetStrikesByTeamIdReq,
strike
} from '@db/schema/strike.ts';
import {
editReportStatus,
type EditReportStatusReq,
@ -130,6 +166,13 @@ import {
type DeleteBlockedUserReq,
deleteBlockedUser
} from '@db/schema/blockedUser.ts';
import {
addReportAttachment,
type AddReportAttachmentReq,
getReportAttachments,
type GetReportAttachmentsReq,
reportAttachment
} from '@db/schema/reportAttachment.ts';
export class Database {
protected readonly db: MySql2Database<{
@ -141,6 +184,7 @@ export class Database {
blockedUser: typeof blockedUser;
death: typeof death;
report: typeof report;
reportAttachment: typeof reportAttachment;
reportStatus: typeof reportStatus;
strike: typeof strike;
strikeReason: typeof strikeReason;
@ -168,6 +212,7 @@ export class Database {
blockedUser,
death,
report,
reportAttachment,
reportStatus,
strike,
strikeReason,
@ -217,6 +262,7 @@ export class Database {
getTeamById = (values: GetTeamByIdReq) => getTeamById(this.db, values);
getTeamByName = (values: GetTeamByNameReq) => getTeamByName(this.db, values);
getTeamByUserUuid = (values: GetTeamByUserUuidReq) => getTeamByUserUuid(this.db, values);
getTeamsByUsername = (values: GetTeamsByUsernameReq) => getTeamsByUsername(this.db, values);
/* team draft */
addTeamDraft = (values: AddTeamDraftReq) => addTeamDraft(this.db, values);
@ -227,15 +273,26 @@ export class Database {
/* team member */
addTeamMember = (values: AddTeamMemberReq) => addTeamMember(this.db, values);
deleteTeamMemberByTeamId = (values: DeleteTeamMemberByTeamIdReq) => deleteTeamMemberByTeamId(this.db, values);
getTeamMembersByTeamId = (values: GetTeamMembersByTeamIdReq) => getTeamMembersByTeamId(this.db, values);
/* death */
addDeath = (values: AddDeathReq) => addDeath(this.db, values);
editDeath = (values: EditDeathReq) => editDeath(this.db, values);
deleteDeath = (values: DeleteDeathReq) => deleteDeath(this.db, values);
getDeathByUserId = (values: GetDeathByUserIdReq) => getDeathByUserId(this.db, values);
getDeaths = (values: GetDeathsReq) => getDeaths(this.db, values);
/* report */
addReport = (values: AddReportReq) => addReport(this.db, values);
editReport = (values: EditReportReq) => editReport(this.db, values);
submitReport = (values: SubmitReportReq) => submitReport(this.db, values);
getReports = (values: GetReportsReq) => getReports(this.db, values);
getReportById = (values: GetReportById) => getReportById(this.db, values);
getReportByUrlHash = (values: GetReportByUrlHash) => getReportByUrlHash(this.db, values);
/* report attachment */
addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values);
getReportAttachments = (values: GetReportAttachmentsReq) => getReportAttachments(this.db, values);
/* report status */
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);
@ -248,12 +305,17 @@ export class Database {
getStrikeReasons = (values: GetStrikeReasonsReq) => getStrikeReasons(this.db, values);
/* strikes */
getStrikesByTeamId = (values: GetStrikesByTeamId) => getStrikesByTeamId(this.db, values);
editStrike = (values: EditStrikeReq) => editStrike(this.db, values);
deleteStrike = (values: DeleteStrikeReq) => deleteStrike(this.db, values);
getStrikeByReportId = (values: GetStrikeByReportIdReq) => getStrikeByReportId(this.db, values);
getStrikesByTeamId = (values: GetStrikesByTeamIdReq) => getStrikesByTeamId(this.db, values);
/* feedback */
addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values);
addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values);
submitFeedback = (values: SubmitFeedbackReq) => submitFeedback(this.db, values);
getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values);
getFeedbackByUrlHash = (values: GetFeedbackByUrlHash) => getFeedbackByUrlHash(this.db, values);
/* settings */
getSettings = (values: GetSettingsReq) => getSettings(this.db, values);

View File

@ -1,4 +1,4 @@
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
import { alias, int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { user } from './user.ts';
import { eq } from 'drizzle-orm';
@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
type Database = MySql2Database<{ death: typeof death }>;
export const death = mysqlTable('death', {
id: int('id').primaryKey().autoincrement(),
message: varchar('message', { length: 1024 }).notNull(),
deadUserId: int('dead_user_id')
.notNull()
@ -15,19 +16,46 @@ export const death = mysqlTable('death', {
export type AddDeathReq = {
message: string;
killerUserId?: number;
killerUserId?: number | null;
deadUserId: number;
};
export type EditDeathReq = {
id: number;
message: string;
killerUserId?: number | null;
deadUserId: number;
};
export type DeleteDeathReq = {
id: number;
};
export type GetDeathByUserIdReq = {
userId: number;
};
export type GetDeathsReq = {};
export type GetDeathsRes = (typeof death.$inferSelect)[];
export async function addDeath(db: Database, values: AddDeathReq) {
await db.insert(death).values(values);
const ids = await db.insert(death).values(values).$returningId();
return ids[0];
}
export async function editDeath(db: Database, values: EditDeathReq) {
await db
.update(death)
.set({
message: values.message,
killerUserId: values.killerUserId,
deadUserId: values.deadUserId
})
.where(eq(death.id, values.id));
}
export async function deleteDeath(db: Database, values: DeleteDeathReq) {
await db.delete(death).where(eq(death.id, values.id));
}
export async function getDeathByUserId(db: Database, values: GetDeathByUserIdReq) {
@ -36,7 +64,24 @@ export async function getDeathByUserId(db: Database, values: GetDeathByUserIdReq
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function getDeaths(db: Database, values: GetDeathsReq): Promise<GetDeathsReq> {
return db.query.death.findMany();
export async function getDeaths(db: Database, _values: GetDeathsReq) {
const killed = alias(user, 'killed');
const killer = alias(user, 'killer');
return db
.select({
id: death.id,
message: death.message,
killed: {
id: killed.id,
username: killed.username
},
killer: {
id: killer.id,
username: killer.username
}
})
.from(death)
.innerJoin(killed, eq(death.deadUserId, killed.id))
.leftJoin(killer, eq(death.killerUserId, killer.id));
}

View File

@ -12,7 +12,7 @@ export const feedback = mysqlTable('feedback', {
title: varchar('title', { length: 255 }),
content: text('content'),
urlHash: varchar('url_hash', { length: 255 }).unique().notNull(),
lastChanged: timestamp('last_changed', { mode: 'date' }).notNull().defaultNow(),
lastChanged: timestamp('last_changed', { mode: 'date' }).notNull().defaultNow().onUpdateNow(),
userId: int('user_id').references(() => user.id)
});
@ -27,8 +27,17 @@ export type AddUserFeedbacksReq = {
uuids: string[];
};
export type SubmitFeedbackReq = {
urlHash: string;
content: string;
};
export type GetFeedbacksReq = {};
export type GetFeedbackByUrlHash = {
urlHash: string;
};
export async function addFeedback(db: Database, values: AddFeedbackReq) {
return db.insert(feedback).values({
event: values.event,
@ -58,8 +67,16 @@ export async function addUserFeedbacks(db: Database, values: AddUserFeedbacksReq
return userFeedbacks;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
export async function submitFeedback(db: Database, values: SubmitFeedbackReq) {
return db
.update(feedback)
.set({
content: values.content
})
.where(eq(feedback.urlHash, values.urlHash));
}
export async function getFeedbacks(db: Database, _values: GetFeedbacksReq) {
return db
.select({
id: feedback.id,
@ -73,3 +90,9 @@ export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
.from(feedback)
.leftJoin(user, eq(feedback.userId, user.id));
}
export async function getFeedbackByUrlHash(db: Database, values: GetFeedbackByUrlHash) {
return db.query.feedback.findFirst({
where: eq(feedback.urlHash, values.urlHash)
});
}

View File

@ -1,10 +1,12 @@
import { alias, int, mysqlTable, text, timestamp, varchar } from 'drizzle-orm/mysql-core';
import { strike } from './strike.ts';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { and, eq } from 'drizzle-orm';
import { reportStatus } from './reportStatus.ts';
import { generateRandomString } from '@util/random.ts';
import { team } from '@db/schema/team.ts';
import { BASE_PATH } from 'astro:env/server';
import { strikeReason } from '@db/schema/strikeReason.ts';
import { strike } from '@db/schema/strike.ts';
type Database = MySql2Database<{ report: typeof report }>;
@ -13,40 +15,76 @@ export const report = mysqlTable('report', {
reason: varchar('reason', { length: 255 }).notNull(),
body: text('body'),
urlHash: varchar('url_hash', { length: 255 }).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }),
reporterTeamId: int('reporter_team_id')
.notNull()
.references(() => team.id),
createdAt: timestamp('created_at', { mode: 'date' }),
reporterTeamId: int('reporter_team_id').references(() => team.id),
reportedTeamId: int('reported_team_id').references(() => team.id)
});
export type AddReportReq = {
reason: string;
body: string | null;
createdAt?: string | null;
reporterTeamId: number;
createdAt?: Date | null;
reporterTeamId?: number;
reportedTeamId?: number | null;
};
export type EditReportReq = {
id: number;
reportedTeamId: number | null;
};
export type SubmitReportReq = {
urlHash: string;
reason: string;
body: string;
};
export type GetReportsReq = {
reporter?: string | null;
reported?: string | null;
};
export type GetReportById = {
id: number;
};
export type GetReportByUrlHash = {
urlHash: string;
};
export async function addReport(db: Database, values: AddReportReq) {
const urlHash = generateRandomString(16);
const r = await db
.insert(report)
.values({
reason: values.reason,
body: values.body,
urlHash: generateRandomString(16),
urlHash: urlHash,
createdAt: values.createdAt,
reporterTeamId: values.reporterTeamId,
reportedTeamId: values.reportedTeamId
})
.$returningId();
return r[0];
return Object.assign(r[0], { url: `${BASE_PATH}/report/${urlHash}` });
}
export async function editReport(db: Database, values: EditReportReq) {
return db.update(report).set({
reportedTeamId: values.reportedTeamId
});
}
export async function submitReport(db: Database, values: SubmitReportReq) {
return db
.update(report)
.set({
reason: values.reason,
body: values.body,
createdAt: new Date()
})
.where(eq(report.urlHash, values.urlHash));
}
export async function getReports(db: Database, values: GetReportsReq) {
@ -71,6 +109,48 @@ export async function getReports(db: Database, values: GetReportsReq) {
}
return db
.select({
id: report.id,
reason: report.reason,
body: report.body,
urlHash: report.urlHash,
createdAt: report.createdAt,
reporter: {
id: reporterTeam.id,
name: reporterTeam.name
},
reported: {
id: reportedTeam.id,
name: reportedTeam.name
},
status: {
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement
},
strike: {
strikeReasonId: strikeReason.id
}
})
.from(report)
.innerJoin(reporterTeam, eq(report.reporterTeamId, reporterTeam.id))
.leftJoin(reportedTeam, eq(report.reportedTeamId, reportedTeam.id))
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
.leftJoin(strike, eq(report.id, strike.reportId))
.leftJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
.where(
and(
values.reporter != null ? eq(report.reporterTeamId, reporterIdSubquery!.id) : undefined,
values.reported != null ? eq(report.reportedTeamId, reportedIdSubquery!.id) : undefined
)
);
}
export async function getReportById(db: Database, values: GetReportById) {
const reporterTeam = alias(team, 'reporter');
const reportedTeam = alias(team, 'reported');
const reports = await db
.select({
id: report.id,
reason: report.reason,
@ -94,11 +174,41 @@ export async function getReports(db: Database, values: GetReportsReq) {
.innerJoin(reporterTeam, eq(report.reporterTeamId, reporterTeam.id))
.leftJoin(reportedTeam, eq(report.reportedTeamId, reportedTeam.id))
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
.leftJoin(strike, eq(reportStatus.strikeId, strike.id))
.where(
and(
values.reporter != null ? eq(report.reporterTeamId, reporterIdSubquery!.id) : undefined,
values.reported != null ? eq(report.reportedTeamId, reportedIdSubquery!.id) : undefined
)
);
.where(eq(report.id, values.id));
return reports[0] ?? null;
}
export async function getReportByUrlHash(db: Database, values: GetReportByUrlHash) {
const reporterTeam = alias(team, 'reporter');
const reportedTeam = alias(team, 'reported');
const reports = await db
.select({
id: report.id,
reason: report.reason,
body: report.body,
createdAt: report.createdAt,
urlHash: report.urlHash,
reporter: {
id: reporterTeam.id,
name: reporterTeam.name
},
reported: {
id: reportedTeam.id,
name: reportedTeam.name
},
status: {
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement
}
})
.from(report)
.innerJoin(reporterTeam, eq(report.reporterTeamId, reporterTeam.id))
.leftJoin(reportedTeam, eq(report.reportedTeamId, reportedTeam.id))
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
.where(eq(report.urlHash, values.urlHash));
return reports[0] ?? null;
}

View File

@ -0,0 +1,38 @@
import { char, int, mysqlEnum, mysqlTable } from 'drizzle-orm/mysql-core';
import { report } from '@db/schema/report.ts';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { eq } from 'drizzle-orm';
type Database = MySql2Database<{ reportAttachment: typeof reportAttachment }>;
export const reportAttachment = mysqlTable('report_attachment', {
type: mysqlEnum('type', ['image', 'video']),
hash: char('hash', { length: 32 }),
reportId: int('report_id')
.notNull()
.references(() => report.id)
});
export type AddReportAttachmentReq = {
type: 'image' | 'video';
hash: string;
reportId: number;
};
export type GetReportAttachmentsReq = {
reportId: number;
};
export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) {
await db.insert(reportAttachment).values(values);
}
export async function getReportAttachments(db: Database, values: GetReportAttachmentsReq) {
return db
.select({
type: reportAttachment.type,
hash: reportAttachment.hash
})
.from(reportAttachment)
.where(eq(reportAttachment.reportId, values.reportId));
}

View File

@ -1,5 +1,4 @@
import { int, mysqlEnum, mysqlTable, text } from 'drizzle-orm/mysql-core';
import { strike } from './strike.ts';
import { admin } from './admin.ts';
import { report } from './report.ts';
import type { MySql2Database } from 'drizzle-orm/mysql2';
@ -15,8 +14,7 @@ export const reportStatus = mysqlTable('report_status', {
.notNull()
.unique()
.references(() => report.id),
reviewerId: int('reviewer_id').references(() => admin.id),
strikeId: int('strike_id').references(() => strike.id)
reviewerId: int('reviewer_id').references(() => admin.id)
});
export type GetReportStatusReq = {
@ -28,7 +26,6 @@ export type EditReportStatusReq = {
status: 'open' | 'closed' | null;
notice: string | null;
statement: string | null;
strikeId: number | null;
};
export async function getReportStatus(db: Database, values: GetReportStatusReq) {
@ -47,8 +44,7 @@ export async function editReportStatus(db: Database, values: EditReportStatusReq
set: {
status: values.status,
notice: values.notice,
statement: values.statement,
strikeId: values.strikeId
statement: values.statement
}
});
}

View File

@ -32,7 +32,18 @@ export async function getSettings(db: Database, values: GetSettingsReq) {
}
export async function setSettings(db: Database, values: SetSettingsReq) {
await db.insert(settings).values(values.settings);
return db.transaction(async (tx) => {
for (const setting of values.settings) {
await tx
.insert(settings)
.values(setting)
.onDuplicateKeyUpdate({
set: {
value: setting.value
}
});
}
});
}
export async function getSetting(db: Database, values: GetSettingReq): Promise<string | null> {

View File

@ -3,32 +3,79 @@ import { strikeReason } from '@db/schema/strikeReason.ts';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { eq } from 'drizzle-orm';
import { report } from '@db/schema/report.ts';
import { reportStatus } from '@db/schema/reportStatus.ts';
type Database = MySql2Database<{ strike: typeof strike }>;
export const strike = mysqlTable('strike', {
id: int('id').primaryKey().autoincrement(),
at: timestamp('at', { mode: 'string' }).notNull(),
at: timestamp('at', { mode: 'date' }).notNull(),
reportId: int('report_id')
.notNull()
.references(() => report.id),
strikeReasonId: int('strike_reason_id')
.notNull()
.references(() => strikeReason.id)
});
export type GetStrikesByTeamId = {
export type EditStrikeReq = {
reportId: number;
at?: Date;
strikeReasonId: number;
};
export type DeleteStrikeReq = {
reportId: number;
};
export type GetStrikeByReportIdReq = {
reportId: number;
};
export type GetStrikesByTeamIdReq = {
teamId: number;
};
export async function getStrikesByTeamId(db: Database, values: GetStrikesByTeamId) {
export async function editStrike(db: Database, values: EditStrikeReq) {
return db
.insert(strike)
.values({
at: values.at ?? new Date(),
reportId: values.reportId,
strikeReasonId: values.strikeReasonId
})
.onDuplicateKeyUpdate({
set: {
at: values.at ?? new Date(),
strikeReasonId: values.strikeReasonId
}
});
}
export async function deleteStrike(db: Database, values: DeleteStrikeReq) {
return db.delete(strike).where(eq(strike.reportId, values.reportId)).limit(1);
}
export async function getStrikeByReportId(db: Database, values: GetStrikeByReportIdReq) {
const strikes = await db
.select({
strike,
strikeReason
})
.from(strike)
.where(eq(strike.reportId, values.reportId))
.leftJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id));
return strikes[0] ?? null;
}
export async function getStrikesByTeamId(db: Database, values: GetStrikesByTeamIdReq) {
return db
.select({
id: strike.id,
at: strike.at,
report: report,
reason: strikeReason
})
.from(strike)
.innerJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
.innerJoin(reportStatus, eq(strike.id, reportStatus.strikeId))
.innerJoin(report, eq(reportStatus.reportId, report.id))
.innerJoin(report, eq(strike.reportId, report.id))
.where(eq(report.reportedTeamId, values.teamId));
}

View File

@ -1,12 +1,11 @@
import { char, int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { aliasedTable, and, asc, desc, eq, like, sql } from 'drizzle-orm';
import { aliasedTable, and, asc, desc, eq, like, or, sql } from 'drizzle-orm';
import { teamMember } from './teamMember.ts';
import { user } from './user.ts';
import { teamDraft } from './teamDraft.ts';
import { death } from '@db/schema/death.ts';
import { report } from '@db/schema/report.ts';
import { reportStatus } from '@db/schema/reportStatus.ts';
import { strikeReason } from '@db/schema/strikeReason.ts';
import { strike } from '@db/schema/strike.ts';
@ -50,6 +49,11 @@ export type GetTeamByUserUuidReq = {
uuid: string;
};
export type GetTeamsByUsernameReq = {
username: string;
limit?: number;
};
export async function addTeam(db: Database, values: AddTeamReq) {
let color = values.color;
if (!color) {
@ -123,8 +127,10 @@ export async function getTeams(db: Database, values: GetTeamsReq) {
.where(
and(
values?.name != null ? like(team.name, `%${values.name}%`) : undefined,
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
or(
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
)
)
)
.orderBy(asc(team.id))
@ -142,8 +148,7 @@ export async function getTeamsFull(db: Database, _values: GetTeamsFullReq) {
})
.from(strike)
.innerJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
.innerJoin(reportStatus, eq(strike.id, reportStatus.strikeId))
.innerJoin(report, eq(reportStatus.reportId, report.id))
.innerJoin(report, eq(strike.reportId, report.id))
.innerJoin(team, eq(report.reportedTeamId, team.id))
.as('strike_weight_subquery');
@ -184,6 +189,18 @@ export async function getTeamByUserUuid(db: Database, values: GetTeamByUserUuidR
return teams[0] ?? null;
}
export async function getTeamsByUsername(db: Database, values: GetTeamsByUsernameReq) {
return db
.select({
user: user,
team: team
})
.from(user)
.where(like(user.username, `%${values.username}%`))
.innerJoin(teamMember, eq(user.id, teamMember.userId))
.innerJoin(team, eq(teamMember.teamId, team.id));
}
const teamColors = [
'#cd853f',
'#ff7f50',

View File

@ -24,6 +24,10 @@ export type DeleteTeamMemberByTeamIdReq = {
teamId: number;
};
export type GetTeamMembersByTeamIdReq = {
teamId: number;
};
export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
const teamMemberIds = await db.insert(teamMember).values(values).$returningId();
@ -33,3 +37,13 @@ export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
export async function deleteTeamMemberByTeamId(db: Database, values: DeleteTeamMemberByTeamIdReq) {
await db.delete(teamMember).where(eq(teamMember.teamId, values.teamId));
}
export async function getTeamMembersByTeamId(db: Database, values: GetTeamMembersByTeamIdReq) {
return db
.select({
user: user
})
.from(teamMember)
.innerJoin(user, eq(teamMember.userId, user.id))
.where(eq(teamMember.teamId, values.teamId));
}

View File

@ -1,16 +1,18 @@
---
import { ClientRouter } from 'astro:transitions';
import { BASE_PATH } from 'astro:env/server';
import favicon from '../assets/favicon.png';
import logo512 from '@assets/img/logo-512.webp';
import favicon from '@assets/favicon.png';
interface Props {
title: string;
description?: string;
keywords?: string[];
openGraph?: boolean;
viewTransition?: boolean;
}
const { title, description, keywords, viewTransition } = Astro.props;
const { title, description, keywords, openGraph, viewTransition } = Astro.props;
---
<!doctype html>
@ -21,7 +23,16 @@ const { title, description, keywords, viewTransition } = Astro.props;
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/png" href={favicon.src} />
<title>{title}</title>
<meta name="og:title" content={title} />
{
openGraph && (
<>
<meta name="og:title" content={title} />
<meta property="og:url" content={Astro.url.pathname} />
<meta property="og:type" content="website" />
<meta property="og:image" content={logo512.src} />
</>
)
}
<meta name="description" content={description} />
{keywords && <meta name="keywords" content={keywords.join(', ')} />}
<base href=`${BASE_PATH}/` />

View File

@ -40,6 +40,13 @@ const adminTabs = [
href: 'admin/teams',
name: 'Teams',
icon: 'heroicons:users',
subTabs: [
{
href: 'admin/teams/dead',
name: 'Tote Spieler',
icon: 'heroicons:x-mark'
}
],
enabled: session?.permissions.users
},
{
@ -72,6 +79,12 @@ const adminTabs = [
name: 'Einstellungen',
icon: 'heroicons:adjustments-horizontal',
enabled: session?.permissions.settings
},
{
href: 'admin/tools',
name: 'Tools',
icon: 'heroicons:wrench-screwdriver',
enabled: session?.permissions.tools
}
];
---
@ -92,26 +105,29 @@ const adminTabs = [
}
<div class="divider mx-1 my-1"></div>
{
adminTabs.map((tab) => (
<li>
<a href={tab.href}>
<Icon name={tab.icon} />
<span>{tab.name}</span>
</a>
{tab.subTabs && (
<ul>
{tab.subTabs.map((subTab) => (
<li>
<a href={subTab.href}>
<Icon name={subTab.icon} />
<span>{subTab.name}</span>
</a>
</li>
))}
</ul>
)}
</li>
))
adminTabs.map(
(tab) =>
tab.enabled && (
<li>
<a href={tab.href}>
<Icon name={tab.icon} />
<span>{tab.name}</span>
</a>
{tab.subTabs && (
<ul>
{tab.subTabs.map((subTab) => (
<li>
<a href={subTab.href}>
<Icon name={subTab.icon} />
<span>{subTab.name}</span>
</a>
</li>
))}
</ul>
)}
</li>
)
)
}
{
Astro.slots.has('actions') && (

View File

@ -14,7 +14,7 @@ interface Props {
const { title, description, footer = true } = Astro.props;
---
<BaseLayout title={title} description={description} viewTransition>
<BaseLayout title={title} description={description} openGraph={true} viewTransition>
<main class="min-h-[calc(100vh-3.5rem)] h-full w-full relative">
<slot />
</main>

View File

@ -0,0 +1,27 @@
import type { APIRoute } from 'astro';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import fs from 'node:fs';
import path from 'node:path';
import { UPLOAD_PATH } from 'astro:env/server';
export const GET: APIRoute = async ({ params, cookies }) => {
Session.actionSessionFromCookies(cookies, Permissions.Reports);
if (!UPLOAD_PATH) return new Response(null, { status: 404 });
const fileHash = params.fileHash as string;
const filePath = path.join(UPLOAD_PATH, fileHash);
if (!fs.existsSync(filePath)) return new Response(null, { status: 404 });
const fileStat = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath);
return new Response(fileStream as any, {
status: 200,
headers: {
'Content-Length': fileStat.size.toString()
}
});
};

View File

@ -0,0 +1,16 @@
---
import AdminLayout from '@layouts/admin/AdminLayout.astro';
import SidebarActions from '@app/admin/teamDeaths/SidebarActions.svelte';
import TeamDeaths from '@app/admin/teamDeaths/TeamDeaths.svelte';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { BASE_PATH } from 'astro:env/server';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---
<AdminLayout title="Tote Spieler">
<SidebarActions slot="actions" client:load />
<TeamDeaths client:load />
</AdminLayout>

View File

@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { BASE_PATH } from 'astro:env/server';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---

View File

@ -0,0 +1,14 @@
---
import { Session } from '@util/session';
import { Permissions } from '@util/permissions';
import { BASE_PATH } from 'astro:env/server';
import AdminLayout from '@layouts/admin/AdminLayout.astro';
import Tools from '@app/admin/tools/Tools.svelte';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Tools);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---
<AdminLayout title="Reports">
<Tools client:load />
</AdminLayout>

View File

@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { BASE_PATH } from 'astro:env/server';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---

View File

@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { BASE_PATH } from 'astro:env/server';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---

View File

@ -30,7 +30,7 @@ const team = [
<WebsiteLayout title="Team">
<div class="m-auto flex flex-col justify-center items-center px-4 py-6 2xl:px-48 sm:py-12">
<h1 class="text-5xl mb-10">Das Team</h1>
<h1 class="text-5xl mb-10">Die Admins</h1>
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4 my-4 justify-center">
{
team.map((member) => (
@ -42,7 +42,7 @@ const team = [
<img
class="m-[7.5px]"
style="width: 85%; height: 85%"
src={`https://mc-heads.net/head/${member.nickname}`}
src={`https://mc-heads.net/head/${member.nickname.toLowerCase()}`}
alt={member.name}
/>
</div>

View File

@ -0,0 +1,107 @@
import type { APIRoute } from 'astro';
import { z } from 'astro:schema';
import { API_SECRET } from 'astro:env/server';
import { db } from '@db/database.ts';
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
const postSchema = z.object({
reporter: z.string(),
reported: z.string().nullable(),
reason: z.string()
});
export const POST: APIRoute = async ({ request }) => {
if (API_SECRET && request.headers.get('authorization') !== `Basic ${API_SECRET}`) {
return new Response(null, { status: 401 });
}
let parsed;
try {
parsed = await postSchema.parseAsync(await request.json());
} catch (_) {
return new Response(null, { status: 400 });
}
const reporterTeam = await db.getTeamByUserUuid({ uuid: parsed.reporter });
if (!reporterTeam) return new Response(null, { status: 404 });
let reportedTeam = null;
if (parsed.reported) {
reportedTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
if (!reportedTeam) return new Response(null, { status: 404 });
}
const report = await db.addReport({
reporterTeamId: reporterTeam.team.id,
reportedTeamId: reportedTeam?.team.id,
reason: parsed.reason,
body: null
});
return new Response(JSON.stringify({ url: report.url }), { status: 200 });
};
const putSchema = z.object({
reporter: z.string().nullable(),
reported: z.string(),
reason: z.string(),
body: z.string().nullable(),
notice: z.string().nullable(),
statement: z.string().nullable(),
strike_reason_id: z.number()
});
export const PUT: APIRoute = async ({ request }) => {
if (API_SECRET && request.headers.get('authorization') !== `Basic ${API_SECRET}`) {
return new Response(null, { status: 401 });
}
let parsed;
try {
parsed = await putSchema.parseAsync(await request.json());
} catch (_) {
return new Response(null, { status: 400 });
}
let reporterTeam = null;
if (parsed.reported) {
reporterTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
if (!reporterTeam) return new Response(null, { status: 404 });
}
const reportedTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
if (!reportedTeam) return new Response(null, { status: 404 });
await db.transaction(async (tx) => {
const report = await tx.addReport({
reporterTeamId: reporterTeam?.team.id,
reportedTeamId: reportedTeam.team.id,
createdAt: new Date(),
reason: parsed.reason,
body: parsed.body
});
await tx.editReportStatus({
reportId: report.id,
notice: parsed.notice,
statement: parsed.statement,
status: 'closed'
});
await tx.editStrike({
reportId: report.id,
strikeReasonId: parsed.strike_reason_id
});
});
const strikes = await db.getStrikesByTeamId({ teamId: reportedTeam.team.id });
const teamMembers = await db.getTeamMembersByTeamId({ teamId: reportedTeam.team.id });
// send webhook in background
sendWebhook(WebhookAction.Strike, {
users: teamMembers.map((tm) => tm.user.uuid!),
totalWeight: strikes.map((strike) => strike.reason.weight).reduce((a, b) => a + b, 0)
});
return new Response(null, { status: 200 });
};

View File

@ -157,13 +157,17 @@ Projekts.</p>`
questions: [
{
title: 'Gibt es eine Worldborder?',
content: `<p>Ja, es gibt außerdem eine Worldborder, die sich mit der Zeit außerhalb der Spielzeiten langsam
content: `<p>Ja, es gibt außerdem eine Worldborder, die sich mit der Zeit - außerhalb der Spielzeiten - langsam
verkleinert. Du wirst im Spiel über alles wichtige informiert, sobald du dich der Border gefährlich näherst.</p>`
},
{
title: 'Kann ich ein eigenes Netherportal bauen?',
content: `<p>Nein, das einzige Netherportal steht am Spawn. Weitere können nicht eröffnet werden.</p>`
},
{
title: 'Darf ich Fallen bauen?',
content: `<p>Ja, du darfst überall Fallen bauen, außer unmittellbar um das Nether-Portal am Spawn.</p>`
},
{
title: 'Wie kann ich einen Report erstellen?',
content: `<p>Neben der Kontrolle durch die Admins kann mit /report ein Report erstellt werden, um Regelverstöße
@ -184,10 +188,10 @@ zu melden. Ohne feste Belege, also einer Bildschirmaufnahme, ist ein Report bei
{
title: 'Warum sind diese Spielinhalte deaktiviert?',
content: `<p>Unser Ziel ist es, ein großartiges Projekt für alle Beteiligten auf die Beine zu stellen. Varo
haben wir in dieser Form und Größe noch nie veranstaltet auch für uns ist es also ein neues Kapitel, bei dem wir
haben wir in dieser Form und Größe noch nie veranstaltet - auch für uns ist es also ein neues Kapitel, bei dem wir
wertvolle Erfahrungen sammeln wollen.<br>Wir haben die Regeln nach bestem Wissen und Gewissen so aufgestellt, um das
bestmögliche Spielerlebnis schaffen. Trotzdem ist uns dein Feedback wichtig: Nach dem Projekt holen wir deine Meinung
ein, um Varo und auch unsere künftigen Projekte nachhaltig weiterzuentwickeln.</p>`
ein, um Varo - und auch unsere künftigen Projekte - nachhaltig weiterzuentwickeln.</p>`
}
]
}

View File

@ -32,82 +32,91 @@ import Input from '@components/input/Input.svelte';
import { popupState } from '@components/popup/Popup';
import { actionErrorPopup } from '../util/action';
const form = document.getElementById('feedback-contact') as HTMLFormElement;
const type = document.getElementById('type') as HTMLSelectElement;
const content = document.getElementById('content') as HTMLTextAreaElement;
const email = document.getElementById('email') as HTMLInputElement;
function setupForm() {
const form = document.getElementById('feedback-contact') as HTMLFormElement;
const type = document.getElementById('type') as HTMLSelectElement;
const content = document.getElementById('content') as HTMLTextAreaElement;
const email = document.getElementById('email') as HTMLInputElement;
// reset form on site (re-)load
form.reset();
// reset form on site (re-)load
form.reset();
type.addEventListener('change', () => {
if (type.value === 'website-feedback') {
// content input
content.previousElementSibling!.firstChild!.textContent = 'Feedback';
// email input
email.parentElement!.hidden = true;
email.required = false;
} else if (type.value === 'website-contact') {
// content input
content.previousElementSibling!.firstChild!.textContent = 'Anfrage';
// email input
email.required = true;
email.parentElement!.hidden = false;
}
});
email.required = false;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (type.value === 'website-feedback') {
confirmPopupState.set({
title: 'Feedback abschicken',
message: 'Soll das Feedback abgeschickt werden?',
onConfirm: () => sendFeedback().then(() => form.reset())
});
} else if (type.value === 'website-contact') {
confirmPopupState.set({
title: 'Kontaktanfrage abschicken',
message: 'Soll die Kontaktanfrage abgeschickt werden?',
onConfirm: () => sendContact().then(() => form.reset())
});
}
});
async function sendFeedback() {
const { error } = await actions.feedback.addWebsiteFeedback({
content: content.value
type.addEventListener('change', () => {
if (type.value === 'website-feedback') {
// content input
content.previousElementSibling!.firstChild!.textContent = 'Feedback';
// email input
email.parentElement!.hidden = true;
email.required = false;
} else if (type.value === 'website-contact') {
// content input
content.previousElementSibling!.firstChild!.textContent = 'Anfrage';
// email input
email.required = true;
email.parentElement!.hidden = false;
}
});
if (error) {
actionErrorPopup(error);
return;
}
email.required = false;
popupState.set({
type: 'info',
title: 'Feedback abgeschickt',
message: 'Dein Feedback wurde abgeschickt. Vielen Dank, dass du uns hilfst, das Projekt besser zu machen!'
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (type.value === 'website-feedback') {
confirmPopupState.set({
title: 'Feedback abschicken',
message: 'Soll das Feedback abgeschickt werden?',
onConfirm: () => sendFeedback().then(() => form.reset())
});
} else if (type.value === 'website-contact') {
confirmPopupState.set({
title: 'Kontaktanfrage abschicken',
message: 'Soll die Kontaktanfrage abgeschickt werden?',
onConfirm: () => sendContact().then(() => form.reset())
});
}
});
const sendFeedback = async () => {
const { error } = await actions.feedback.addWebsiteFeedback({
content: content.value
});
if (error) {
actionErrorPopup(error);
return;
}
popupState.set({
type: 'info',
title: 'Feedback abgeschickt',
message: 'Dein Feedback wurde abgeschickt. Vielen Dank, dass du uns hilfst, das Projekt besser zu machen!'
});
};
const sendContact = async () => {
const { error } = await actions.feedback.addWebsiteContact({
content: content.value,
email: email.value
});
if (error) {
actionErrorPopup(error);
return;
}
popupState.set({
type: 'info',
title: 'Kontaktanfrage abgeschickt',
message: 'Deine Kontaktanfrage wurde abgeschickt. Jemand aus dem Team wird sich nächstmöglich bei Dir melden.'
});
};
}
async function sendContact() {
const { error } = await actions.feedback.addWebsiteContact({
content: content.value,
email: email.value
});
const pathname = document.location.pathname;
document.addEventListener('astro:page-load', () => {
if (document.location.pathname !== pathname) return;
if (error) {
actionErrorPopup(error);
return;
}
popupState.set({
type: 'info',
title: 'Kontaktanfrage abgeschickt',
message: 'Deine Kontaktanfrage wurde abgeschickt. Jemand aus dem Team wird sich nächstmöglich bei Dir melden.'
});
}
setupForm();
});
</script>

View File

@ -0,0 +1,66 @@
---
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import { db } from '@db/database.ts';
const { urlHash } = Astro.params;
const feedback = urlHash ? await db.getFeedbackByUrlHash({ urlHash: urlHash }) : null;
if (!feedback) {
return new Response(null, { status: 404 });
}
---
<WebsiteLayout title="Feedback">
<div class="flex justify-center items-center">
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
<h2 class="text-3xl text-center">Feedback</h2>
<form id="feedback" data-url-hash={urlHash}>
<div class="space-y-4 mt-6 mb-4">
<Input value={feedback.title} label="Event" dynamicWidth readonly />
<Textarea
id="content"
value={feedback.content}
label="Feedback"
rows={10}
dynamicWidth
required
readonly={feedback.content !== null}
/>
</div>
<button id="send" class="btn" disabled>Feedback senden</button>
</form>
</div>
</div>
</WebsiteLayout>
<script>
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action';
document.addEventListener('astro:page-load', () => {
const form = document.getElementById('feedback') as HTMLFormElement;
const content = document.getElementById('content') as HTMLTextAreaElement;
const sendButton = document.getElementById('send') as HTMLButtonElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await actions.feedback.submitFeedback({
urlHash: form.dataset.urlHash!,
content: content.value
});
if (error) {
actionErrorPopup(error);
return;
}
content.readOnly = true;
sendButton.disabled = true;
});
content.addEventListener('input', () => (sendButton.disabled = content.value === '' || content.readOnly));
});
</script>

View File

@ -5,7 +5,7 @@ import Teams from '@app/website/index/Teams.svelte';
import Countdown from '@app/website/index/Countdown.svelte';
import Varo from '@assets/img/varo.webp';
import Background from '@assets/img/background.webp';
import { START_DATE } from 'astro:env/server';
import { START_DATE, YOUTUBE_INTRO_LINK } from 'astro:env/server';
import { getSetting, SettingKey } from '@util/settings';
import { db } from '@db/database.ts';
@ -13,18 +13,19 @@ const teams = await db.getTeams({});
const deaths = await db.getDeaths({});
const signupEnabled = await getSetting(db, SettingKey.SignupEnabled, false);
const signupInfoMessage = await getSetting(db, SettingKey.SignupInfoMessage);
const information = [
{
title: 'Was ist Varo?',
description:
'Varo ist ein spannendes Vanilla-Minecraft-PvP-Projekt, bei dem Zweier-Teams im Kampf ums Überleben gegeneinander antreten. Wer stirbt ganz gleich auf welche Weise scheidet endgültig aus. Das letzte verbleibende Team gewinnt Varo!'
'Varo ist ein spannendes Vanilla-Minecraft-PvP-Projekt, bei dem Zweier-Teams im Kampf ums Überleben gegeneinander antreten. Wer stirbt - ganz gleich auf welche Weise - scheidet endgültig aus. Das letzte verbleibende Team gewinnt Varo!'
},
{
title: 'Warum Varo?',
description:
'Varo 5 ist unser erstes großes Sommerprojekt ein PvP-Format, das für frische Abwechslung sorgt und die Wartezeit auf das nächsten CraftAttack verkürzt.\n' +
'Tatsächlich ist es schon das fünfte Varo auch wenn das viele überraschen dürfte.\n' +
'Varo 5 ist unser erstes großes Sommerprojekt - ein PvP-Format, das für frische Abwechslung sorgt und die Wartezeit auf das nächste CraftAttack verkürzt.\n' +
'Tatsächlich ist es schon das fünfte Varo - auch wenn das viele überraschen dürfte.\n' +
'Zum ersten Mal veranstalten wir es jedoch in dieser offenen Form für alle Teilnehmenden.\n' +
'Wenn es euch gefällt, könnte Varo künftig als feste Sommerreihe weitergeführt werden.\n' +
'Und natürlich gilt: Im Winter sehen wir uns wie gewohnt bei CraftAttack wieder!'
@ -32,7 +33,7 @@ const information = [
{
title: 'Wer kann mitspielen?',
description:
'Alle Spieler mit einem Minecraft Java-Account sind herzlich eingeladen, mitzumachen. Wenn du dabei sein willst, brauchst du nur einen Teampartner gemeinsam könnt ihr euch direkt hier auf unserer Website anmelden.'
'Alle Spieler mit einem Minecraft Java-Account sind herzlich eingeladen, mitzumachen. Wenn du dabei sein willst, brauchst du nur einen Teampartner - gemeinsam könnt ihr euch direkt hier auf unserer Website anmelden.'
}
];
---
@ -64,6 +65,7 @@ const information = [
>{signupEnabled ? 'Jetzt registrieren' : 'Infos zur Anmeldung'}</a
>
</div>
{signupInfoMessage && <span class="text-center text-xs text-base-content/80 mt-3">{signupInfoMessage}</span>}
</div>
</div>
</div>
@ -78,12 +80,37 @@ const information = [
</div>
</div>
{
YOUTUBE_INTRO_LINK && (
<div class="bg-base-300 w-full py-12 flex justify-center">
<iframe
width="624"
height="351"
src={YOUTUBE_INTRO_LINK}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
/>
</div>
)
}
<div class="fixed bottom-0 sm:right-0 m-5 hidden sm:block">
<Scroll href="#teams" client:load />
</div>
<div class="bg-base-100 flex flex-col space-y-10 items-center py-10">
<h2 id="teams" class="text-4xl">Teams</h2>
<h2 id="teams" class="text-4xl mb-10">Teams</h2>
{
signupEnabled && (
<p class="text-sm text-center mb-2 mx-1">
Bei unvollständigen Teams muss sich der zweite Mitspieler noch registrieren. Unvollständige Teams werden bei
Anmeldeschluss gelöscht.
</p>
)
}
<Teams {teams} {deaths} />
</div>
</WebsiteLayout>

View File

@ -0,0 +1,23 @@
---
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
import { db } from '@db/database.ts';
import Draft from './_draft.astro';
import Submitted from './_submitted.astro';
import Popup from '@components/popup/Popup.svelte';
import ConfirmPopup from '@components/popup/ConfirmPopup.svelte';
const { urlHash } = Astro.params;
const report = urlHash ? await db.getReportByUrlHash({ urlHash: urlHash }) : null;
if (!report) {
return new Response(null, { status: 404 });
}
---
<WebsiteLayout title="Report">
{report.createdAt === null ? <Draft report={report} /> : <Submitted report={report} />}
</WebsiteLayout>
<Popup client:idle />
<ConfirmPopup client:idle />

View File

@ -0,0 +1,105 @@
---
import type { db } from '@db/database.ts';
import AdversarySearch from '@app/website/report/AdversarySearch.svelte';
import Dropzone from '@app/website/report/Dropzone.svelte';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import { MAX_UPLOAD_BYTES } from 'astro:env/server';
interface Props {
report: Awaited<ReturnType<db.getReportByUrlHash>>;
}
const { report } = Astro.props;
---
<div class="flex justify-center items-center">
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
<h2 class="text-3xl text-center">
Report von Team <span class="underline">A</span> gegen Team <span id="adversary-team-name" class="underline"
>B</span
>
</h2>
<form id="report" data-url-hash={report.urlHash} data-adversary-team={report.reported?.name}>
<div class="space-y-4 my-4">
<div class="flex flex-col gap-4">
<AdversarySearch
adversary={{ type: report.reported ? 'team' : 'unknown', name: report.reported?.name }}
client:load
/>
<Input id="reason" value={report.reason} label="Report Grund" dynamicWidth />
<Textarea id="body" value={report.body} label="Details" rows={10} dynamicWidth required />
<Dropzone maxFilesBytes={MAX_UPLOAD_BYTES} client:load />
</div>
<button id="send" class="btn" disabled={report.body}>Report senden</button>
</div>
</form>
</div>
</div>
<script>
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action';
import { popupState } from '@components/popup/Popup';
document.addEventListener('astro:page-load', () => {
const eventCancelController = new AbortController();
document.addEventListener('astro:after-swap', () => eventCancelController.abort());
const adversary = document.getElementById('adversary-team-name') as HTMLSpanElement;
const form = document.getElementById('report') as HTMLFormElement;
const reason = document.getElementById('reason') as HTMLInputElement;
const body = document.getElementById('body') as HTMLTextAreaElement;
const sendButton = document.getElementById('send') as HTMLButtonElement;
let attachments: File[] = [];
body.addEventListener('change', () => {
sendButton.disabled = !body.value;
});
document.addEventListener(
'adversaryInput',
(e: any & { detail: { adversaryTeamName: string } }) => {
adversary.textContent = e.detail.adversaryTeamName;
},
{ signal: eventCancelController.signal }
);
document.addEventListener(
'dropzoneInput',
(e: any & { detail: { files: File[] } }) => {
attachments = e.detail.files;
},
{ signal: eventCancelController.signal }
);
form.addEventListener(
'submit',
async (e) => {
e.preventDefault();
const formData = new FormData();
formData.set('urlHash', form.dataset.urlHash!);
formData.set('reason', reason.value);
formData.set('body', body.value);
for (const attachment of attachments) {
formData.append('files', attachment);
}
const { error } = await actions.report.submitReport(formData);
if (error) {
actionErrorPopup(error);
return;
}
popupState.set({
type: 'info',
title: 'Report abgeschickt',
message: 'Der Report wurde abgeschickt. Ein Admin wird sich schnellstmöglich darum kümmern.',
onClose: () => location.reload()
});
},
{ signal: eventCancelController.signal }
);
});
</script>

View File

@ -0,0 +1,33 @@
---
import type { db } from '@db/database.ts';
import Textarea from '@components/input/Textarea.svelte';
interface Props {
report: Awaited<ReturnType<db.getReportByUrlHash>>;
}
const { report } = Astro.props;
---
<div class="flex justify-center items-center">
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
{
report.status?.status == null ? (
<p>Dein Report wird in kürze bearbeitet</p>
) : report.status?.status === 'open' ? (
<p>Dein Report befindet sich in Bearbeitung</p>
) : (
<>
<p>Dein Report wurde bearbeitet</p>
<Textarea
value={report.status?.statement}
label="Antwort vom Admin Team (optional)"
rows={5}
dynamicWidth
readonly
/>
</>
)
}
</div>
</div>

View File

@ -36,7 +36,7 @@ const signupDisabledSubMessage = signupSetting[SettingKey.SignupDisabledSubMessa
label="Vorname"
required
validation={{
pattern: '^\\w{2,}',
pattern: '^\\p{L}{2,}',
hint: 'Bitte gib Deinen vollständigen Vornamen an, dieser muss mindestens aus 2 Zeichen bestehen.'
}}
dynamicWidth
@ -47,7 +47,7 @@ const signupDisabledSubMessage = signupSetting[SettingKey.SignupDisabledSubMessa
label="Nachname"
required
validation={{
pattern: '^\\w{2,}',
pattern: '^\\p{L}{2,}',
hint: 'Bitte gib Deinen vollständigen Nachnamen an, dieser muss mindestens aus 2 Zeichen bestehen.'
}}
dynamicWidth
@ -155,89 +155,99 @@ const signupDisabledSubMessage = signupSetting[SettingKey.SignupDisabledSubMessa
import { teamPopupName, teamPopupOpen } from '@app/website/signup/TeamPopup';
import { registeredPopupState } from '@app/website/signup/RegisteredPopup';
/* ----- client validation ----- */
const rulesCheckbox = document.getElementById('rules')! as HTMLInputElement;
const rulesCheckboxRulesLink = rulesCheckbox.nextElementSibling!.querySelector('.link') as HTMLAnchorElement;
function setupClientValidation() {
const rulesCheckbox = document.getElementById('rules') as HTMLInputElement;
const rulesCheckboxRulesLink = rulesCheckbox.nextElementSibling!.querySelector('.link') as HTMLAnchorElement;
// add popup state subscriber to check when the accepted button is clicked
rulesPopupState.subscribe((value) => {
if (value == 'accepted') rulesCheckbox.checked = true;
});
// add click handler to open rules popup to rules checkbox
rulesCheckbox.addEventListener('click', (e) => {
if (!rulesPopupRead.get()) {
e.preventDefault();
rulesPopupState.set('open');
}
});
// add click handler to open rules popup when clicking the rules link in the rules checkbox label
rulesCheckboxRulesLink!.addEventListener('click', () => rulesPopupState.set('open'));
/* ----- signup form ----- */
const form = document.getElementById('signup')! as HTMLFormElement;
// reset form on site (re-)load
form.reset();
async function sendSignup() {
const { data, error } = await actions.signup.signup({
firstname: form.firstname.value,
lastname: form.lastname.value,
birthday: form.birthday.value,
phone: form.phone.value,
username: form.username.value,
teamMember: form.teamMember.value,
teamName: teamPopupName.get()
// add popup state subscriber to check when the accepted button is clicked
rulesPopupState.subscribe((value) => {
if (value == 'accepted') rulesCheckbox.checked = true;
});
// must be done in order to show the team popup again if it's closed or an error occurs
teamPopupName.set(null);
if (error) {
if (error.code == 'BAD_REQUEST') {
teamPopupOpen.set(true);
const close = teamPopupName.listen(() => {
close();
sendSignup();
});
} else if (error.code == 'CONFLICT' || error.code == 'FORBIDDEN') {
popupState.set({
type: 'error',
title: 'Fehler',
message: error.message
});
} else {
popupState.set({
type: 'error',
title: 'Fehler',
message: error.message
});
// add click handler to open rules popup to rules checkbox
rulesCheckbox.addEventListener('click', (e) => {
if (!rulesPopupRead.get()) {
e.preventDefault();
rulesPopupState.set('open');
}
return;
}
registeredPopupState.set({
firstname: form.firstname.value,
lastname: form.lastname.value,
birthday: form.birthday.value,
phone: form.phone.value,
username: form.username.value,
team: data.team.name,
teamMember: form.teamMember.value,
teamColor: data.team.color
});
const cancel = registeredPopupState.subscribe((value) => {
if (value) return;
cancel();
form.reset();
// add click handler to open rules popup when clicking the rules link in the rules checkbox label
rulesCheckboxRulesLink!.addEventListener('click', () => rulesPopupState.set('open'));
}
function setupForm() {
const form = document.getElementById('signup')! as HTMLFormElement;
// reset form on site (re-)load
form.reset();
const sendSignup = async () => {
const { data, error } = await actions.signup.signup({
firstname: form.firstname.value,
lastname: form.lastname.value,
birthday: form.birthday.value,
phone: form.phone.value,
username: form.username.value,
teamMember: form.teamMember.value,
teamName: teamPopupName.get()
});
// must be done in order to show the team popup again if it's closed or an error occurs
teamPopupName.set(null);
if (error) {
if (error.code == 'BAD_REQUEST') {
teamPopupOpen.set(true);
const close = teamPopupName.listen(() => {
close();
sendSignup();
});
} else if (error.code == 'CONFLICT' || error.code == 'FORBIDDEN') {
popupState.set({
type: 'error',
title: 'Fehler',
message: error.message
});
} else {
popupState.set({
type: 'error',
title: 'Fehler',
message: error.message
});
}
return;
}
registeredPopupState.set({
firstname: form.firstname.value,
lastname: form.lastname.value,
birthday: form.birthday.value,
phone: form.phone.value,
username: form.username.value,
team: data.team.name,
teamMember: form.teamMember.value,
teamColor: data.team.color
});
const cancel = registeredPopupState.subscribe((value) => {
if (value) return;
cancel();
form.reset();
});
};
form.addEventListener('submit', (e) => {
e.preventDefault();
sendSignup();
});
}
form.addEventListener('submit', (e) => {
e.preventDefault();
sendSignup();
const pathname = document.location.pathname;
document.addEventListener('astro:page-load', () => {
if (document.location.pathname !== pathname) return;
setupClientValidation();
setupForm();
});
</script>

View File

@ -15,12 +15,12 @@ Teampartner darf aber weiterhin spielen und kann das Projekt für das Team gewin
title: 'Start',
content: `<p><b>Varo 5 startet am 23. Juni um 19:00 Uhr!</b><br>
Achtung: Wenn ein Team(also beide Spieler) nicht beim Projektstart um genau 19:00 Uhr dabei ist, darf es erst ab Tag 2
einsteigen und verliert die 30 Minuten des ersten Tages.</p>`
einsteigen - und verliert die 30 Minuten des ersten Tages.</p>`
},
{
title: 'Team',
content: `<p>Solange beide Teampartner leben, dürfen diese <b>ausnahmslos nur gleichzeitig</b> auf dem Server
sein. Wenn einer der Spielpartner den Server verlässt ob absichtlich oder durch einen Verbindungsabbruch wird der
sein. Wenn einer der Spielpartner den Server verlässt - ob absichtlich oder durch einen Verbindungsabbruch - wird der
andere Spielpartner automatisch ebenfalls getrennt.</p>`
},
{
@ -39,8 +39,8 @@ am Stück</b> auf dem Server spielen. Spielt das Team an einem Tag nicht, darf e
title: 'Strike-Übersicht',
content: `<p>Regelverstöße führen zu Strikes für das gesamte Team, die folgendes zur Folge haben:<br>
1. Strike - einmalige Koordinatenveröffentlichung<br>
2. Strike vollständiges Löschen der Inventare<br>
3. Strike Disqualifikation des Teams</p>`
2. Strike - vollständiges Löschen der Inventare<br>
3. Strike - Disqualifikation des Teams</p>`
},
{
title: 'Mögliche Regelverstöße',
@ -74,10 +74,10 @@ langfristigem Projektausschluss rechnen.</p>`
title: 'Ingame (Border, Deaktivierte Spielinhalte, usw.)',
content: `<p>Gespielt wird in der <b>aktuellsten Minecraft Java Version</b> mit dem Schwierigkeitsgrad „Normal“.
<br>
Es gibt außerdem eine <b>Worldborder</b>, die sich mit der Zeit außerhalb der Spielzeiten langsam verkleinert. Die
Es gibt außerdem eine <b>Worldborder</b>, die sich mit der Zeit - außerhalb der Spielzeiten - langsam verkleinert. Die
Spieler werden im Spiel über alles wichtige informiert, sobald sie sich der Border gefährlich nähern.<br>
Das einzige <b>Netherportal</b> steht am Spawn. Weitere können nicht eröffnet werden. Für das Beste Spielerlebnis sind
folgende <b>Spielinhalte deaktiviert</b>:
Das einzige <b>Netherportal</b> steht am Spawn. Unmittelbar um das Nether-Portal dürfen keine Fallen errichtet werden.
Weitere können nicht eröffnet werden. Für das Beste Spielerlebnis sind folgende <b>Spielinhalte deaktiviert</b>:
<ol class="list-disc pl-8 py-3">
<li>alle Netherite Items</li>
<li>alle Tränke der Stufe 2, ausgenommen Direktheilung 2</li>

2
src/util/media.ts Normal file
View File

@ -0,0 +1,2 @@
export const allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
export const allowedVideoTypes = ['video/mp4', 'video/webm'];

View File

@ -1,10 +1,10 @@
export async function getJavaUuid(username: string) {
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
if (!response.ok) {
// rate limit
if (response.status == 429) return null;
// user doesn't exist
else if (response.status < 500) throw new Error();
if (response.status == 400 || response.status == 404) throw new Error();
// rate limit
else if (response.status == 429) return null;
return null;
}
const json = await response.json();
@ -12,3 +12,46 @@ export async function getJavaUuid(username: string) {
// prettier-ignore
return `${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}`;
}
// https://github.com/carlop3333/XUIDGrabber/blob/main/grabber.js
export async function getBedrockUuid(username: string): Promise<string> {
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
const initialPageContent = await initialPageResponse.text();
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
if (token === undefined || cookies === undefined || cookies.length < 11) return null;
const requestBody = new URLSearchParams();
requestBody.set('_token', token);
requestBody.set('gamertag', username);
const resultPageResponse = await fetch('https://cxkes.me/xbox/xuid', {
method: 'post',
body: requestBody,
// prettier-ignore
headers: {
'Host': 'www.cxkes.me',
'Accept-Encoding': 'gzip, deflate,br',
'Content-Length': Buffer.byteLength(requestBody.toString()).toString(),
'Origin': 'https://www.cxkes.me',
'DNT': '1',
'Connection': 'keep-alive',
'Referer': 'https://www.cxkes.me/xbox/xuid',
'Cookie': `${cookies[0]} ${cookies[10].slice(0, cookies[10].length - 1)}`,
'Upgrade-Insecure-Requests': '1',
'Sec-Fectch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-User': '?1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const resultPageContent = await resultPageResponse.text();
let xuid: string | undefined;
if ((xuid = /id="xuidHex">(?<xuid>\w+)</.exec(resultPageContent)?.groups?.xuid) === undefined) throw new Error();
return `00000000-0000-0000-${xuid.substring(0, 4)}-${xuid.substring(4)}`;
}

View File

@ -1,9 +1,8 @@
export function getObjectEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
let entry = data;
for (const part of key.split('.')) {
if ((entry = entry[part]) === undefined) {
return undefined;
}
entry = entry[part];
if (entry === null || typeof entry !== 'object') return entry;
}
return entry;
}

View File

@ -53,10 +53,10 @@ export class Permissions {
return (this.value & Permissions.Reports.value) != 0;
}
get feedback() {
return (this.value & Permissions.Reports.value) != 0;
return (this.value & Permissions.Feedback.value) != 0;
}
get settings() {
return (this.value & Permissions.Reports.value) != 0;
return (this.value & Permissions.Settings.value) != 0;
}
get tools() {
return (this.value & Permissions.Tools.value) != 0;

View File

@ -2,9 +2,9 @@ import type { AstroCookies, AstroCookieSetOptions } from 'astro';
import { ActionError } from 'astro:actions';
import crypto from 'node:crypto';
import { Permissions } from './permissions.ts';
import { ADMIN_COOKIE } from 'astro:env/server';
export class Session {
static readonly #cookieName = 'muelleel';
static readonly #cookieOptions: AstroCookieSetOptions = {
httpOnly: true,
path: '/',
@ -28,7 +28,7 @@ export class Session {
for (let i = 0; i < Session.#sessions.length; i++) {
if (Session.#sessions[i] == this) {
Session.#sessions = Session.#sessions.splice(i, 1);
if (cookies) cookies.delete(Session.#cookieName, Session.#cookieOptions);
if (cookies) cookies.delete(ADMIN_COOKIE, Session.#cookieOptions);
break;
}
}
@ -38,13 +38,13 @@ export class Session {
const session = new Session(crypto.randomBytes(16).toString('hex'), adminId, permissions);
Session.#sessions.push(session);
cookies.set(Session.#cookieName, session.sessionId, Session.#cookieOptions);
cookies.set(ADMIN_COOKIE, session.sessionId, Session.#cookieOptions);
return session;
}
static sessionFromCookies(cookies: AstroCookies, neededPermissions?: Permissions) {
const sessionId = cookies.get(Session.#cookieName);
const sessionId = cookies.get(ADMIN_COOKIE);
if (!sessionId) return null;
for (const session of Session.#sessions) {
@ -60,7 +60,7 @@ export class Session {
}
static actionSessionFromCookies(cookies: AstroCookies, neededPermissions?: Permissions) {
const sessionId = cookies.get(Session.#cookieName);
const sessionId = cookies.get(ADMIN_COOKIE);
if (!sessionId) throw new ActionError({ code: 'UNAUTHORIZED' });
for (const session of Session.#sessions) {

View File

@ -39,12 +39,14 @@ export async function getSettings<K extends SettingKey[]>(db: Database, keys?: K
export enum SettingKey {
SignupEnabled = 'signup.enabled',
SignupInfoMessage = 'signup.infoMessage',
SignupDisabledMessage = 'signup.disabledMessage',
SignupDisabledSubMessage = 'signup.disabledSubMessage'
}
export type SettingKeyValueType<K extends SettingKey> = {
[SettingKey.SignupEnabled]: boolean;
[SettingKey.SignupInfoMessage]: string;
[SettingKey.SignupDisabledMessage]: string;
[SettingKey.SignupDisabledSubMessage]: string;
}[K];

View File

@ -18,11 +18,12 @@ export async function sendWebhook<T extends WebhookAction>(action: T, data: Webh
while (true) {
try {
const response = await fetch(WEBHOOK_ENDPOINT, {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-webhook-action': action
},
keepalive: false
body: JSON.stringify(data)
});
if (response.status === 200) return;