Compare commits
97 Commits
b610b30a78
...
main
Author | SHA1 | Date | |
---|---|---|---|
2f6b3521cd | |||
6789a65285 | |||
7a0db65f78 | |||
94aa6ea377 | |||
a06cc34085 | |||
9041578252 | |||
deafb65c75 | |||
2eb9891b3c | |||
36fe39845f | |||
d82ac4f275 | |||
136e0b808c | |||
d29e761efb | |||
018e239c35 | |||
1f96e3babe | |||
e5f253ebc1 | |||
6e4a7f0ac9 | |||
55f5f25e5a | |||
03ee87d7cf | |||
9a6e44b2d5 | |||
1a81b5fb06 | |||
d7b05deff2 | |||
e9e44f67a2 | |||
eeeca4ed4e | |||
7b5557bd76 | |||
29a80935ff | |||
023fd67004 | |||
daa1de302b | |||
e0c91483fb | |||
bff1a4bda6 | |||
46494ed8dc | |||
4e615fe211 | |||
54a780d999 | |||
94e9e83e93 | |||
eb39cae44c | |||
e0b9850efb | |||
12f8b9c43d | |||
faa3eaa007 | |||
9092012cf7 | |||
ee8f595ecc | |||
9c49585873 | |||
28951534ee | |||
17f32d6d91 | |||
c6f8053468 | |||
ab46e00c71 | |||
afcff1959d | |||
e22d3fd7e6 | |||
bf5e144e55 | |||
3f28377f57 | |||
6051c4dd69 | |||
8cc103510c | |||
019ef4d444 | |||
b10f400e4a | |||
e04d1bf7bf | |||
0a513d2350 | |||
057a287277 | |||
5c41857530 | |||
8262fd90aa | |||
56deba780d | |||
5a8203d122 | |||
aacc815676 | |||
afd3541d4d | |||
e770a72f9d | |||
506f8646cb | |||
acb74e3d29 | |||
ed43212d9c | |||
86f448a6e3 | |||
b8ed48a68b | |||
eb45e03f16 | |||
11b0ee4083 | |||
564aa9eeda | |||
c1c5af73dd | |||
fc156c386b | |||
a8a6424098 | |||
c1dcaf05bf | |||
56e0e15a04 | |||
e09a232f3c | |||
54352d7b73 | |||
b8112d66db | |||
cb0c453279 | |||
1d43eb8b90 | |||
6a9f88c9f7 | |||
dc9ef2bb84 | |||
95daaddb9b | |||
c177832fbe | |||
6aa492cf42 | |||
a9a70b74e9 | |||
ce6325e8dd | |||
358701309d | |||
29a5aaf4cb | |||
ece07ada12 | |||
1732aa9fea | |||
a21d3d283a | |||
45f984e4da | |||
e47268111a | |||
8b18623232 | |||
ba1146facf | |||
7e6a09563a |
@ -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
|
||||
|
@ -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"
|
||||
|
73
README.md
73
README.md
@ -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>
|
||||
|
||||
|
@ -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' }),
|
||||
|
@ -33,6 +33,16 @@ export const admin = {
|
||||
await db.editAdmin(input);
|
||||
}
|
||||
}),
|
||||
deleteAdmin: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.deleteAdmin(input);
|
||||
}
|
||||
}),
|
||||
admins: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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,51 @@ 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(),
|
||||
weight: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
return await db.addStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
editStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
weight: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.editStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
deleteStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
|
||||
|
||||
await db.deleteStrikeReason(input);
|
||||
}
|
||||
}),
|
||||
strikeReasons: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
@ -76,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 }))
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
@ -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,36 @@ 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 });
|
||||
if (blockedUser) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Du bist für die Registrierung gesperrt'
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
if (input.teamName) {
|
||||
|
@ -105,6 +105,16 @@ export const team = {
|
||||
});
|
||||
}
|
||||
}),
|
||||
deleteTeam: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteTeam(input);
|
||||
}
|
||||
}),
|
||||
teams: defineAction({
|
||||
input: z.object({
|
||||
name: z.string().nullish(),
|
||||
@ -118,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
57
src/actions/tools.ts
Normal 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
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
@ -70,6 +70,16 @@ export const user = {
|
||||
});
|
||||
}
|
||||
}),
|
||||
deleteUser: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteUser(input);
|
||||
}
|
||||
}),
|
||||
users: defineAction({
|
||||
input: z.object({
|
||||
username: z.string().nullish(),
|
||||
@ -84,5 +94,51 @@ export const user = {
|
||||
users: users
|
||||
};
|
||||
}
|
||||
}),
|
||||
addBlocked: defineAction({
|
||||
input: z.object({
|
||||
uuid: z.string(),
|
||||
comment: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const { id } = await db.addBlockedUser(input);
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editBlocked: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
uuid: z.string(),
|
||||
comment: z.string().nullable()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.editBlockedUser(input);
|
||||
}
|
||||
}),
|
||||
deleteBlocked: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteBlockedUser(input);
|
||||
}
|
||||
}),
|
||||
blocked: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
return {
|
||||
blocked: await db.getBlockedUsers({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
@ -1,63 +1,63 @@
|
||||
<script lang="ts">
|
||||
import Badges from './Badges.svelte';
|
||||
import BitBadge from '@components/input/BitBadge.svelte';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import type { Admin } from './types.ts';
|
||||
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
||||
import { admins } from './state.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { editAdmin } from './actions.ts';
|
||||
import { type Admin, admins, deleteAdmin, editAdmin } from '@app/admin/admins/admins.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// consts
|
||||
const availablePermissionBadges = {
|
||||
[Permissions.Admin.value]: 'Admin',
|
||||
[Permissions.Users.value]: 'Users',
|
||||
[Permissions.Reports.value]: 'Reports',
|
||||
[Permissions.Feedback.value]: 'Feedback',
|
||||
[Permissions.Settings.value]: 'Settings',
|
||||
[Permissions.Tools.value]: 'Tools'
|
||||
};
|
||||
// state
|
||||
let editPopupAdmin = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupAdmin);
|
||||
|
||||
// states
|
||||
let editAdminPopupAdmin = $state<Admin | null>(null);
|
||||
// callback
|
||||
function onAdminDelete(admin: Admin) {
|
||||
$confirmPopupState = {
|
||||
title: 'Admin löschen',
|
||||
message: 'Soll der Admin wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteAdmin(admin)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">#</th>
|
||||
<th style="width: 30%">Benutzername</th>
|
||||
<th style="width: 60%">Berechtigungen</th>
|
||||
<th style="width: 5%"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $admins as admin, i (admin)}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td>{i + 1}</td>
|
||||
<td>{admin.username}</td>
|
||||
<td>
|
||||
<Badges available={availablePermissionBadges} set={new Permissions(admin.permissions).toNumberArray()} />
|
||||
</td>
|
||||
<td>
|
||||
<button class="cursor-pointer" onclick={() => (editAdminPopupAdmin = admin)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#snippet permissionsBadge(permissions: number)}
|
||||
<BitBadge available={Permissions.asOptions()} value={permissions} readonly />
|
||||
{/snippet}
|
||||
|
||||
{#key editAdminPopupAdmin}
|
||||
<CreateOrEditPopup
|
||||
popupTitle="Admin bearbeiten"
|
||||
submitButtonTitle="Admin bearbeiten"
|
||||
confirmPopupTitle="Admin bearbeiten"
|
||||
confirmPopupMessage="Bist du sicher, dass du den Admin bearbeiten möchtest?"
|
||||
admin={editAdminPopupAdmin}
|
||||
open={editAdminPopupAdmin != null}
|
||||
onSubmit={editAdmin}
|
||||
/>
|
||||
{/key}
|
||||
<DataTable
|
||||
data={admins}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'username', label: 'Username', width: 30 },
|
||||
{ key: 'permissions', label: 'Berechtigungen', width: 60, transform: permissionsBadge }
|
||||
]}
|
||||
onEdit={(admin) => (editPopupAdmin = admin)}
|
||||
onDelete={onAdminDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Admin bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupAdmin}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
|
||||
{ key: 'password', type: 'password', label: 'Passwort', default: null, options: { convert: (v) => v || null } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'permissions',
|
||||
type: 'bit-badge',
|
||||
label: 'Berechtigungen',
|
||||
default: 0,
|
||||
options: { available: Permissions.asOptions() }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={editAdmin}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
|
@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
available: { [k: number]: string };
|
||||
set: number[];
|
||||
onUpdate?: (set: number[]) => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { available, set, onUpdate }: Props = $props();
|
||||
let reactiveSet = $state(set);
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const value = Number((e.target as HTMLSelectElement).value);
|
||||
reactiveSet.push(value);
|
||||
|
||||
onUpdate?.(reactiveSet);
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(badge: number) {
|
||||
const index = reactiveSet.indexOf(badge);
|
||||
if (index !== -1) {
|
||||
reactiveSet.splice(index, 1);
|
||||
onUpdate?.(reactiveSet);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if onUpdate}
|
||||
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||
<option selected hidden>-</option>
|
||||
{#each Object.entries(available) as [value, badge] (value)}
|
||||
<option {value} hidden={reactiveSet.indexOf(Number(value)) !== -1}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#each reactiveSet as badge (badge)}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if onUpdate}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(badge)}>✕</button>
|
||||
{/if}
|
||||
<span>{available[badge]}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
@ -1,115 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Badges from './Badges.svelte';
|
||||
import type { Admin } from './types.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import Password from '@components/input/Password.svelte';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
popupTitle: string;
|
||||
submitButtonTitle: string;
|
||||
confirmPopupTitle: string;
|
||||
confirmPopupMessage: string;
|
||||
|
||||
admin: Admin | null;
|
||||
|
||||
open: boolean;
|
||||
|
||||
onSubmit: (admin: Admin & { password: string }) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// consts
|
||||
const availablePermissionBadges = {
|
||||
[Permissions.Admin.value]: 'Admin',
|
||||
[Permissions.Users.value]: 'Users',
|
||||
[Permissions.Reports.value]: 'Reports',
|
||||
[Permissions.Feedback.value]: 'Feedback',
|
||||
[Permissions.Settings.value]: 'Settings',
|
||||
[Permissions.Tools.value]: 'Tools'
|
||||
};
|
||||
|
||||
// inputs
|
||||
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, admin, open, onSubmit, onClose }: Props =
|
||||
$props();
|
||||
|
||||
// states
|
||||
let username = $state<string | null>(admin?.username ?? null);
|
||||
let password = $state<string | null>(null);
|
||||
let permissions = $state<number | null>(admin?.permissions ?? 0);
|
||||
|
||||
let submitEnabled = $derived(!!username && (admin || password));
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (open) modal.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
function onBadgesUpdate(newPermissions: number[]) {
|
||||
permissions = new Permissions(newPermissions).value;
|
||||
}
|
||||
|
||||
function onSaveButtonClick() {
|
||||
$confirmPopupState = {
|
||||
title: confirmPopupTitle,
|
||||
message: confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
onSubmit({
|
||||
id: admin?.id ?? -1,
|
||||
username: username!,
|
||||
password: password!,
|
||||
permissions: permissions!
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||
<form method="dialog" class="modal-box w-min" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
|
||||
<div class="w-full gap-x-4 gap-y-2">
|
||||
<div class="w-[20rem]">
|
||||
<Input type="text" bind:value={username} label="Username" required />
|
||||
<Password bind:value={password} label="Password" required={!admin} />
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Berechtigungen</legend>
|
||||
{#key admin}
|
||||
<Badges
|
||||
available={availablePermissionBadges}
|
||||
set={new Permissions(permissions).toNumberArray()}
|
||||
onUpdate={onBadgesUpdate}
|
||||
/>
|
||||
{/key}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>{submitButtonTitle}</button
|
||||
>
|
||||
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -1,34 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { addAdmin, fetchAdmins } from './actions.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import CreateOrEditPopup from '@app/admin/admins/CreateOrEditPopup.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addAdmin, fetchAdmins } from '@app/admin/admins/admins.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
|
||||
// state
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
onMount(() => {
|
||||
$effect(() => {
|
||||
fetchAdmins();
|
||||
});
|
||||
|
||||
// states
|
||||
let newTeamPopupOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#key newTeamPopupOpen}
|
||||
<CreateOrEditPopup
|
||||
popupTitle="Admin erstellen"
|
||||
submitButtonTitle="Admin erstellen"
|
||||
confirmPopupTitle="Admin erstellen"
|
||||
confirmPopupMessage="Bist du sicher, dass du den Admin erstellen möchtest?"
|
||||
admin={null}
|
||||
open={newTeamPopupOpen}
|
||||
onSubmit={addAdmin}
|
||||
onClose={() => (newTeamPopupOpen = false)}
|
||||
/>
|
||||
{/key}
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Admin erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Admin erstellen?',
|
||||
confirmPopupMessage: 'Soll der Admin erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
|
||||
{ key: 'password', type: 'password', label: 'Passwort', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'permissions',
|
||||
type: 'bit-badge',
|
||||
label: 'Berechtigungen',
|
||||
default: 0,
|
||||
options: { available: Permissions.asOptions() }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addAdmin}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
|
@ -1,41 +0,0 @@
|
||||
import type { Admin } from './types.ts';
|
||||
import { actions } from 'astro:actions';
|
||||
import { admins } from './state.ts';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
export async function fetchAdmins() {
|
||||
const { data, error } = await actions.admin.admins();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
admins.set(data.admins);
|
||||
}
|
||||
|
||||
export async function addAdmin(admin: Admin & { password: string }) {
|
||||
const { data, error } = await actions.admin.addAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
admins.update((old) => {
|
||||
old.push(Object.assign(admin, { id: data.id }));
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
export async function editAdmin(admin: Admin & { password: string }) {
|
||||
const { error } = await actions.admin.editAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
admins.update((old) => {
|
||||
const index = old.findIndex((a) => a.id == admin.id);
|
||||
old[index] = admin;
|
||||
return old;
|
||||
});
|
||||
}
|
52
src/app/admin/admins/admins.ts
Normal file
52
src/app/admin/admins/admins.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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 Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
|
||||
export type Admin = Admins[0];
|
||||
|
||||
// state
|
||||
export const admins = writable<Admin[]>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchAdmins() {
|
||||
const { data, error } = await actions.admin.admins();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
admins.set(data.admins);
|
||||
}
|
||||
|
||||
export async function addAdmin(admin: Admin & { password: string }) {
|
||||
const { data, error } = await actions.admin.addAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(admins, Object.assign(admin, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editAdmin(admin: Admin & { password: string }) {
|
||||
const { error } = await actions.admin.editAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(admins, admin, (t) => t.id == admin.id);
|
||||
}
|
||||
|
||||
export async function deleteAdmin(admin: Admin) {
|
||||
const { error } = await actions.admin.deleteAdmin(admin);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(admins, (t) => t.id == admin.id);
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
import type { Admin } from './types.ts';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const admins = writable<Admin[]>([]);
|
@ -1,4 +0,0 @@
|
||||
import type { ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
export type Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
|
||||
export type Admin = Admins[0];
|
56
src/app/admin/blockedUsers/BlockedUsers.svelte
Normal file
56
src/app/admin/blockedUsers/BlockedUsers.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type BlockedUser,
|
||||
blockedUsers,
|
||||
deleteBlockedUser,
|
||||
editBlockedUser
|
||||
} from '@app/admin/blockedUsers/blockedUsers.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// state
|
||||
let blockedUserEditPopupBlockedUser = $state(null);
|
||||
let blockedUserEditPopupOpen = $derived(!!blockedUserEditPopupBlockedUser);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!blockedUserEditPopupOpen) blockedUserEditPopupBlockedUser = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onBlockedUserDelete(blockedUser: BlockedUser) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer entblockieren?',
|
||||
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
|
||||
onConfirm: () => deleteBlockedUser(blockedUser)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
data={blockedUsers}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'uuid', label: 'UUID', width: 20, sortable: true },
|
||||
{ key: 'comment', label: 'Kommentar', width: 70 }
|
||||
]}
|
||||
onEdit={(blockedUser) => (blockedUserEditPopupBlockedUser = blockedUser)}
|
||||
onDelete={onBlockedUserDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Blockierten Nutzer bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={blockedUserEditPopupBlockedUser}
|
||||
keys={[
|
||||
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'comment', type: 'textarea', label: 'Kommentar', options: { dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={editBlockedUser}
|
||||
bind:open={blockedUserEditPopupOpen}
|
||||
/>
|
37
src/app/admin/blockedUsers/SidebarActions.svelte
Normal file
37
src/app/admin/blockedUsers/SidebarActions.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { fetchBlockedUsers, addBlockedUser } from '@app/admin/blockedUsers/blockedUsers.ts';
|
||||
|
||||
// states
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchBlockedUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer blockierter Nutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Blockierten Nutzer erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Nutzer blockieren',
|
||||
confirmPopupMessage:
|
||||
'Bist du sicher, dass der Nutzer blockiert werden soll?\nEin blockierter Nutzer kann sich nicht mehr registrieren.'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'comment', type: 'textarea', label: 'Kommentar', default: null, options: { dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={addBlockedUser}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
52
src/app/admin/blockedUsers/blockedUsers.ts
Normal file
52
src/app/admin/blockedUsers/blockedUsers.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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 BlockedUsers = Exclude<ActionReturnType<typeof actions.user.blocked>['data'], undefined>['blocked'];
|
||||
export type BlockedUser = BlockedUsers[0];
|
||||
|
||||
// state
|
||||
export const blockedUsers = writable<BlockedUsers>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchBlockedUsers() {
|
||||
const { data, error } = await actions.user.blocked();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
blockedUsers.set(data.blocked);
|
||||
}
|
||||
|
||||
export async function addBlockedUser(blockedUser: BlockedUser) {
|
||||
const { data, error } = await actions.user.addBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(blockedUsers, Object.assign(blockedUser, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editBlockedUser(blockedUser: BlockedUser) {
|
||||
const { error } = await actions.user.editBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(blockedUsers, blockedUser, (t) => t.id == blockedUser.id);
|
||||
}
|
||||
|
||||
export async function deleteBlockedUser(blockedUser: BlockedUser) {
|
||||
const { error } = await actions.user.deleteBlocked(blockedUser);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(blockedUsers, (t) => t.id == blockedUser.id);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Feedback } from './types.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import type { Feedback } from '@app/admin/feedback/feedback.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
@ -20,7 +20,7 @@
|
||||
<div class="w-96">
|
||||
<Input value={feedback?.event} label="Event" readonly />
|
||||
<Input value={feedback?.title} label="Titel" readonly />
|
||||
<Input value={feedback?.user?.username} label="Nutzer" readonly />
|
||||
<Input value={feedback?.username} label="Nutzer" readonly />
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="w-full">
|
||||
|
@ -1,11 +1,8 @@
|
||||
<script lang="ts">
|
||||
import BottomBar from './BottomBar.svelte';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import { feedbacks } from './state.ts';
|
||||
import { fetchFeedbacks } from './actions.ts';
|
||||
import { feedbacks, fetchFeedbacks, type Feedback } from '@app/admin/feedback/feedback.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Feedback } from './types.ts';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
|
||||
// consts
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
@ -25,29 +22,20 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-[70vh] max-h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr data={feedbacks}>
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
<SortableTh style="width: 10%">Event</SortableTh>
|
||||
<SortableTh style="width: 20%" key="user.username">Nutzer</SortableTh>
|
||||
<SortableTh style="width: 20%" key="lastChanged">Datum</SortableTh>
|
||||
<SortableTh style="width: 45%">Inhalt</SortableTh>
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $feedbacks as feedback, i (feedback.id)}
|
||||
<tr class="hover:bg-base-200" onclick={() => (activeFeedback = feedback)}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{feedback.event}</td>
|
||||
<td>{feedback.user?.username}</td>
|
||||
<td>{dateFormat.format(new Date(feedback.lastChanged))}</td>
|
||||
<td>{feedback.content}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#snippet date(value: string)}
|
||||
{dateFormat.format(new Date(value))}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={feedbacks}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'event', label: 'Event', width: 10, sortable: true },
|
||||
{ key: 'username', label: 'Nutzer', width: 10, sortable: true },
|
||||
{ key: 'lastChanged', label: 'Datum', width: 10, sortable: true, transform: date },
|
||||
{ key: 'content', label: 'Inhalt', width: 10 }
|
||||
]}
|
||||
onClick={(feedback) => (activeFeedback = feedback)}
|
||||
/>
|
||||
|
||||
<BottomBar feedback={activeFeedback} />
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { feedbacks } from './state.ts';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { writable } from 'svelte/store';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
|
||||
export type Feedback = Feedbacks[0];
|
||||
|
||||
// state
|
||||
export const feedbacks = writable<Feedbacks>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchFeedbacks(reporter?: string | null, reported?: string | null) {
|
||||
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
|
||||
if (error) {
|
@ -1,4 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Feedbacks } from './types.ts';
|
||||
|
||||
export const feedbacks = writable<Feedbacks>([]);
|
@ -1,4 +0,0 @@
|
||||
import type { ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
|
||||
export type Feedback = Feedbacks[0];
|
@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Report, ReportStatus, StrikeReasons } from './types.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/actions.ts';
|
||||
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>
|
||||
|
@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Checkbox from '@components/input/Checkbox.svelte';
|
||||
import type { Report } from './types.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
||||
onSubmit: (report: Report) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// input
|
||||
let { open, onSubmit, onClose }: Props = $props();
|
||||
|
||||
// form
|
||||
let reason = $state<string | null>(null);
|
||||
let body = $state<string | null>(null);
|
||||
let editable = $state<boolean>(true);
|
||||
let reporter = $state<Report['reporter'] | null>(null);
|
||||
let reported = $state<Report['reported'] | null>(null);
|
||||
|
||||
let submitEnabled = $derived(!!(reason && reporter));
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (open) modal.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
async function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: 'Report erstellen',
|
||||
message: 'Bist du sicher, dass du den Report erstellen möchtest?',
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onSubmit({
|
||||
id: -1,
|
||||
reason: reason!,
|
||||
body: body!,
|
||||
reporter: reporter!,
|
||||
reported: reported!,
|
||||
createdAt: editable ? null : new Date().toISOString(),
|
||||
status: null
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||
<form method="dialog" class="modal-box" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xt font-geist font-bold">Neuer Report</h3>
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<TeamSearch label="Report Team" required mustMatch onSubmit={(team) => (reporter = team)} />
|
||||
<TeamSearch label="Reportetes Team" mustMatch onSubmit={(team) => (reported = team)} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1">
|
||||
<Input label="Grund" bind:value={reason} required dynamicWidth />
|
||||
<Textarea label="Inhalt" bind:value={body} rows={5} dynamicWidth />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 mt-2">
|
||||
<Checkbox label="Report kann bearbeitet werden" bind:checked={editable} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>Erstellen</button
|
||||
>
|
||||
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import { reports } from './state.ts';
|
||||
import type { Report, StrikeReasons } from './types.ts';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import BottomBar from '@app/admin/reports/BottomBar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { getStrikeReasons } from '@app/admin/reports/actions.ts';
|
||||
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>([]);
|
||||
@ -17,33 +23,30 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr data={reports}>
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
<SortableTh>Grund</SortableTh>
|
||||
<SortableTh>Report Team</SortableTh>
|
||||
<SortableTh>Reportetes Team</SortableTh>
|
||||
<SortableTh>Datum</SortableTh>
|
||||
<SortableTh>Bearbeitungsstatus</SortableTh>
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $reports as report, i (report.id)}
|
||||
<tr class="hover:bg-base-200" onclick={() => (activeReport = report)}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{report.reason}</td>
|
||||
<td>{report.reporter.name}</td>
|
||||
<td>{report.reported?.name}</td>
|
||||
<td>{report.createdAt}</td>
|
||||
<td>{report.status?.status}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#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}
|
||||
keys={[
|
||||
{ key: 'reason', label: 'Grund' },
|
||||
{ key: 'reporter.name', label: 'Report Team' },
|
||||
{ key: 'reported.name', label: 'Reportetes Team' },
|
||||
{ key: 'createdAt', label: 'Datum', transform: date },
|
||||
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
|
||||
]}
|
||||
onClick={(report) => (activeReport = report)}
|
||||
/>
|
||||
|
||||
{#key activeReport}
|
||||
<BottomBar {strikeReasons} report={activeReport} />
|
||||
|
@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { addReport, fetchReports } from '@app/admin/reports/actions.ts';
|
||||
import CreatePopup from '@app/admin/reports/CreatePopup.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addReport, fetchReports } from '@app/admin/reports/reports.ts';
|
||||
|
||||
// states
|
||||
let reporterUsernameFilter = $state<string | null>(null);
|
||||
let reportedUsernameFilter = $state<string | null>(null);
|
||||
|
||||
let newReportPopupOpen = $state(false);
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
@ -23,12 +23,48 @@
|
||||
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
|
||||
</fieldset>
|
||||
<div class="divider my-1"></div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (newReportPopupOpen = true)}>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Report</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#key newReportPopupOpen}
|
||||
<CreatePopup open={newReportPopupOpen} onSubmit={addReport} onClose={() => (newReportPopupOpen = false)} />
|
||||
{/key}
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Report erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Report erstellen?',
|
||||
confirmPopupMessage: 'Soll der Report erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{
|
||||
key: 'reporter',
|
||||
type: 'team-search',
|
||||
label: 'Report Team',
|
||||
default: { id: null, name: null },
|
||||
options: { required: true, mustMatch: true, validate: (team) => team?.id != null }
|
||||
},
|
||||
{
|
||||
key: 'reported',
|
||||
type: 'team-search',
|
||||
label: 'Reportetes Team',
|
||||
default: { id: null, name: null },
|
||||
options: { mustMatch: true, validate: (team) => team?.id != null }
|
||||
}
|
||||
],
|
||||
[{ key: 'reason', type: 'text', label: 'Grund', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'body', type: 'textarea', label: 'Inhalt', default: null, options: { rows: 5, dynamicWidth: true } }],
|
||||
[
|
||||
{
|
||||
key: 'createdAt',
|
||||
type: 'checkbox',
|
||||
label: 'Report kann bearbeitet werden',
|
||||
options: { convert: (v) => (v ? null : new Date().toISOString()) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addReport}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
|
@ -1,8 +1,25 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { reports } from './state.ts';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { writable } from 'svelte/store';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
import type { Report, ReportStatus } from './types.ts';
|
||||
|
||||
// types
|
||||
export type Reports = Exclude<ActionReturnType<typeof actions.report.reports>['data'], undefined>['reports'];
|
||||
export type Report = Reports[0];
|
||||
|
||||
export type ReportStatus = Exclude<
|
||||
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
|
||||
null
|
||||
> & { strikeReasonId: number | null };
|
||||
|
||||
export type StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
undefined
|
||||
>['strikeReasons'];
|
||||
|
||||
// state
|
||||
export const reports = writable<Reports>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchReports(reporterUsername: string | null, reportedUsername: string | null) {
|
||||
const { data, error } = await actions.report.reports({ reporter: reporterUsername, reported: reportedUsername });
|
||||
if (error) {
|
||||
@ -17,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
|
||||
});
|
||||
@ -42,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) {
|
||||
@ -56,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) {
|
@ -1,4 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Reports } from './types.ts';
|
||||
|
||||
export const reports = writable<Reports>([]);
|
@ -1,14 +0,0 @@
|
||||
import type { ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
export type Reports = Exclude<ActionReturnType<typeof actions.report.reports>['data'], undefined>['reports'];
|
||||
export type Report = Reports[0];
|
||||
|
||||
export type ReportStatus = Exclude<
|
||||
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
|
||||
null
|
||||
>;
|
||||
|
||||
export type StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
undefined
|
||||
>['strikeReasons'];
|
@ -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>
|
||||
|
@ -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);
|
||||
|
36
src/app/admin/strikeReasons/SidebarActions.svelte
Normal file
36
src/app/admin/strikeReasons/SidebarActions.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import Icon from '@iconify/svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addStrikeReason, fetchStrikeReasons } from '@app/admin/strikeReasons/strikeReasons.js';
|
||||
|
||||
// states
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchStrikeReasons();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Strikegrund</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Strikegrund erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Strikegrund erstellen?',
|
||||
confirmPopupMessage: 'Soll der Strikegrund erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={addStrikeReason}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
57
src/app/admin/strikeReasons/StrikeReasons.svelte
Normal file
57
src/app/admin/strikeReasons/StrikeReasons.svelte
Normal file
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import {
|
||||
deleteStrikeReason,
|
||||
editStrikeReason,
|
||||
type StrikeReason,
|
||||
strikeReasons
|
||||
} from '@app/admin/strikeReasons/strikeReasons.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
|
||||
// state
|
||||
let editPopupStrikeReason = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupStrikeReason);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupStrikeReason = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onBlockedUserDelete(strikeReason: StrikeReason) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer entblockieren?',
|
||||
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
|
||||
onConfirm: () => deleteStrikeReason(strikeReason)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
data={strikeReasons}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'name', label: 'Name', width: 20 },
|
||||
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
|
||||
{ key: 'id', label: 'Id', width: 20 }
|
||||
]}
|
||||
onDelete={onBlockedUserDelete}
|
||||
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Strikegrund bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupStrikeReason}
|
||||
keys={[
|
||||
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
|
||||
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
|
||||
]}
|
||||
onSubmit={editStrikeReason}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
55
src/app/admin/strikeReasons/strikeReasons.ts
Normal file
55
src/app/admin/strikeReasons/strikeReasons.ts
Normal file
@ -0,0 +1,55 @@
|
||||
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 StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
undefined
|
||||
>['strikeReasons'];
|
||||
export type StrikeReason = StrikeReasons[0];
|
||||
|
||||
// state
|
||||
export const strikeReasons = writable<StrikeReasons>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchStrikeReasons() {
|
||||
const { data, error } = await actions.report.strikeReasons();
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
strikeReasons.set(data.strikeReasons);
|
||||
}
|
||||
|
||||
export async function addStrikeReason(strikeReason: StrikeReason) {
|
||||
const { data, error } = await actions.report.addStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(strikeReasons, Object.assign(strikeReason, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editStrikeReason(strikeReason: StrikeReason) {
|
||||
const { error } = await actions.report.editStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(strikeReasons, strikeReason, (t) => t.id == strikeReason.id);
|
||||
}
|
||||
|
||||
export async function deleteStrikeReason(strikeReason: StrikeReason) {
|
||||
const { error } = await actions.report.deleteStrikeReason(strikeReason);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(strikeReasons, (t) => t.id == strikeReason.id);
|
||||
}
|
49
src/app/admin/teamDeaths/SidebarActions.svelte
Normal file
49
src/app/admin/teamDeaths/SidebarActions.svelte
Normal 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}
|
||||
/>
|
69
src/app/admin/teamDeaths/TeamDeaths.svelte
Normal file
69
src/app/admin/teamDeaths/TeamDeaths.svelte
Normal 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}
|
||||
/>
|
61
src/app/admin/teamDeaths/teamDeaths.ts
Normal file
61
src/app/admin/teamDeaths/teamDeaths.ts
Normal 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);
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import type { Team } from '@app/admin/teams/types.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
popupTitle: string;
|
||||
submitButtonTitle: string;
|
||||
confirmPopupTitle: string;
|
||||
confirmPopupMessage: string;
|
||||
|
||||
team: Team | null;
|
||||
|
||||
open: boolean;
|
||||
|
||||
onSubmit: (team: Team) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, team, open, onSubmit, onClose }: Props =
|
||||
$props();
|
||||
|
||||
// states
|
||||
let name = $state<string | null>(team?.name ?? null);
|
||||
let color = $state<string | null>(team?.color ?? '#000000');
|
||||
let lastJoined = $state<string | null>(team?.lastJoined ?? null);
|
||||
let memberOne = $state<Team['memberOne']>(team?.memberOne ?? ({ username: null } as unknown as Team['memberOne']));
|
||||
let memberTwo = $state<Team['memberOne']>(team?.memberTwo ?? ({ username: null } as unknown as Team['memberOne']));
|
||||
|
||||
let submitEnabled = $derived(!!(name && color && memberOne.username && memberTwo.username));
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (open) modal.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
async function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: confirmPopupTitle,
|
||||
message: confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onSubmit({
|
||||
id: team?.id ?? -1,
|
||||
name: name!,
|
||||
color: color!,
|
||||
lastJoined: lastJoined!,
|
||||
memberOne: memberOne!,
|
||||
memberTwo: memberTwo!
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
|
||||
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input type="color" label="Farbe" bind:value={color} required />
|
||||
<Input type="text" label="Name" bind:value={name} required />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UserSearch
|
||||
label="Spieler 1"
|
||||
onSubmit={(user) => {
|
||||
if (user) memberOne = user;
|
||||
}}
|
||||
bind:value={memberOne.username}
|
||||
required
|
||||
mustMatch
|
||||
/>
|
||||
<UserSearch
|
||||
label="Spieler 2"
|
||||
onSubmit={(user) => {
|
||||
if (user) memberTwo = user;
|
||||
}}
|
||||
bind:value={memberTwo.username}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input type="datetime-local" label="Zuletzt gejoined" bind:value={lastJoined}></Input>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>{submitButtonTitle}</button
|
||||
>
|
||||
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { addTeam, fetchTeams } from './actions.ts';
|
||||
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addTeam, fetchTeams } from '@app/admin/teams/teams.ts';
|
||||
|
||||
// states
|
||||
let teamNameFilter = $state<string | null>(null);
|
||||
let memberUsernameFilter = $state<string | null>(null);
|
||||
|
||||
let newTeamPopupOpen = $state(false);
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
@ -23,21 +23,51 @@
|
||||
<Input bind:value={memberUsernameFilter} label="Spieler Username" />
|
||||
</fieldset>
|
||||
<div class="divider my-1"></div>
|
||||
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neues Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#key newTeamPopupOpen}
|
||||
<CreateOrEditPopup
|
||||
popupTitle="Neues Team"
|
||||
submitButtonTitle="Team erstellen"
|
||||
confirmPopupTitle="Team erstellen"
|
||||
confirmPopupMessage="Bist du sicher, dass du das Team erstellen möchtest?"
|
||||
team={null}
|
||||
open={newTeamPopupOpen}
|
||||
onSubmit={addTeam}
|
||||
onClose={() => (newTeamPopupOpen = false)}
|
||||
/>
|
||||
{/key}
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Team erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Team erstellen?',
|
||||
confirmPopupMessage: 'Sollen das neue Team erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'name', type: 'text', label: 'Name', options: { required: true } },
|
||||
{ key: 'color', type: 'color', label: 'Farbe', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'memberOne',
|
||||
type: 'user-search',
|
||||
label: 'Spieler 1',
|
||||
default: { id: null, username: '' },
|
||||
options: { required: true, validate: (user) => !!user.username }
|
||||
},
|
||||
{
|
||||
key: 'memberTwo',
|
||||
type: 'user-search',
|
||||
label: 'Spieler 2',
|
||||
default: { id: null, username: '' },
|
||||
options: { required: true, validate: (user) => !!user.username }
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'lastJoined',
|
||||
type: 'datetime-local',
|
||||
label: 'Zuletzt gejoined',
|
||||
default: null,
|
||||
options: { convert: (date) => (date ? date : null) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addTeam}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
|
@ -1,65 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { teams } from './state.ts';
|
||||
import type { Team } from './types.ts';
|
||||
import { editTeam } from './actions.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
|
||||
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';
|
||||
|
||||
// state
|
||||
let editTeamPopupTeam = $state<Team | null>(null);
|
||||
let editPopupTeam = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupTeam);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupTeam = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onTeamDelete(team: Team) {
|
||||
$confirmPopupState = {
|
||||
title: 'Team löschen?',
|
||||
message: 'Soll das Team wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteTeam(team)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr data={teams}>
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
<SortableTh style="width: 5%">Farbe</SortableTh>
|
||||
<SortableTh style="width: 25%" key="name">Name</SortableTh>
|
||||
<SortableTh style="width: 30%" key="memberOne.username">Spieler 1</SortableTh>
|
||||
<SortableTh style="width: 30%" key="memberTwo.username">Spieler 2</SortableTh>
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $teams as team, i (team.id)}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<div class="rounded-sm w-3 h-3" style="background-color: {team.color}"></div>
|
||||
</td>
|
||||
<td>{team.name}</td>
|
||||
{#if team.memberOne.id != null}
|
||||
<td>{team.memberOne.username}</td>
|
||||
{:else}
|
||||
<td class="text-base-content/30">{team.memberOne.username}</td>
|
||||
{/if}
|
||||
{#if team.memberTwo.id != null}
|
||||
<td>{team.memberTwo.username}</td>
|
||||
{:else}
|
||||
<td class="text-base-content/30">{team.memberTwo.username}</td>
|
||||
{/if}
|
||||
<td>
|
||||
<button class="cursor-pointer" onclick={() => (editTeamPopupTeam = team)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#snippet color(value: string)}
|
||||
<div class="rounded-sm w-3 h-3" style="background-color: {value}"></div>
|
||||
{/snippet}
|
||||
|
||||
{#key editTeamPopupTeam}
|
||||
<CreateOrEditPopup
|
||||
popupTitle="Team bearbeiten"
|
||||
submitButtonTitle="Team bearbeiten"
|
||||
confirmPopupTitle="Team bearbeiten"
|
||||
confirmPopupMessage="Bist du sicher, dass du das Team bearbeiten möchtest?"
|
||||
team={editTeamPopupTeam}
|
||||
open={editTeamPopupTeam != null}
|
||||
onSubmit={editTeam}
|
||||
/>
|
||||
{/key}
|
||||
{#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', label: 'Spieler 1', width: 30, transform: signedUp },
|
||||
{ key: 'memberTwo', label: 'Spieler 2', width: 30, transform: signedUp }
|
||||
]}
|
||||
onEdit={(team) => (editPopupTeam = team)}
|
||||
onDelete={onTeamDelete}
|
||||
/>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Team bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern?',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupTeam}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'name', type: 'text', label: 'Name', options: { required: true } },
|
||||
{ key: 'color', type: 'color', label: 'Farbe', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'memberOne',
|
||||
type: 'user-search',
|
||||
label: 'Spieler 1',
|
||||
options: { required: true, validate: (user) => !!user.username }
|
||||
},
|
||||
{
|
||||
key: 'memberTwo',
|
||||
type: 'user-search',
|
||||
label: 'Spieler 2',
|
||||
default: { id: null, username: null },
|
||||
options: { required: true, validate: (user) => !!user.username }
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'lastJoined',
|
||||
type: 'datetime-local',
|
||||
label: 'Zuletzt gejoined',
|
||||
default: { id: null, username: null },
|
||||
options: { convert: (date) => (date ? date : null) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={editTeam}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
|
@ -1,41 +0,0 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { teams } from './state.ts';
|
||||
import type { Team } from './types.ts';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
export async function fetchTeams(name: string | null, username: string | null) {
|
||||
const { data, error } = await actions.team.teams({ name: name, username: username });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
teams.set(data.teams);
|
||||
}
|
||||
|
||||
export async function addTeam(team: Team) {
|
||||
const { data, error } = await actions.team.addTeam(team);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
teams.update((old) => {
|
||||
old.push(Object.assign(team, { id: data.id }));
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
export async function editTeam(team: Team) {
|
||||
const { error } = await actions.team.editTeam(team);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
teams.update((old) => {
|
||||
const index = old.findIndex((a) => a.id == team.id);
|
||||
old[index] = team;
|
||||
return old;
|
||||
});
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
import type { Teams } from './types.ts';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const teams = writable<Teams>([]);
|
52
src/app/admin/teams/teams.ts
Normal file
52
src/app/admin/teams/teams.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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 Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
export type Team = Teams[0];
|
||||
|
||||
// state
|
||||
export const teams = writable<Teams>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchTeams(name: string | null, username: string | null) {
|
||||
const { data, error } = await actions.team.teams({ name: name, username: username });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
teams.set(data.teams);
|
||||
}
|
||||
|
||||
export async function addTeam(team: Team) {
|
||||
const { data, error } = await actions.team.addTeam(team);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
addToWritableArray(teams, Object.assign(team, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editTeam(team: Team) {
|
||||
const { error } = await actions.team.editTeam(team);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWritableArray(teams, team, (t) => t.id == team.id);
|
||||
}
|
||||
|
||||
export async function deleteTeam(team: Team) {
|
||||
const { error } = await actions.team.deleteTeam(team);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(teams, (t) => t.id == team.id);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
export type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
export type Team = Teams[0];
|
||||
|
||||
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
29
src/app/admin/tools/AccountUuidFinder.svelte
Normal file
29
src/app/admin/tools/AccountUuidFinder.svelte
Normal 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>
|
7
src/app/admin/tools/Tools.svelte
Normal file
7
src/app/admin/tools/Tools.svelte
Normal 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>
|
12
src/app/admin/tools/tools.ts
Normal file
12
src/app/admin/tools/tools.ts
Normal 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;
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { User } from './types.ts';
|
||||
import { userCreateOrEditPopupState } from './state.ts';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// input
|
||||
let action = $state<'create' | 'edit' | null>(null);
|
||||
let user = $state({} as User);
|
||||
let onUpdate = $state((_: User) => {});
|
||||
|
||||
// lifecycle
|
||||
const cancel = userCreateOrEditPopupState.subscribe((value) => {
|
||||
if (value && 'create' in value) {
|
||||
action = 'create';
|
||||
user = {
|
||||
id: -1,
|
||||
username: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
birthday: new Date().toISOString().slice(0, 10),
|
||||
telephone: '',
|
||||
uuid: ''
|
||||
};
|
||||
onUpdate = value?.create.onUpdate;
|
||||
modal.show();
|
||||
} else if (value && 'edit' in value) {
|
||||
action = 'edit';
|
||||
user = value.edit.user;
|
||||
onUpdate = value.edit.onUpdate;
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
|
||||
// texts
|
||||
const texts = {
|
||||
create: {
|
||||
title: 'Nutzer erstellen',
|
||||
buttonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Nutzer erstellen?',
|
||||
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
|
||||
},
|
||||
edit: {
|
||||
title: 'Nutzer bearbeiten',
|
||||
buttonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderunge speichern?',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
},
|
||||
null: {}
|
||||
};
|
||||
|
||||
// callbacks
|
||||
function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: texts[action!].confirmPopupTitle,
|
||||
message: texts[action!].confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onUpdate(user);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal}>
|
||||
<form method="dialog" class="modal-box" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{texts[action!].title}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
|
||||
<Input type="text" bind:value={user.firstname} label="Vorname" />
|
||||
<Input type="text" bind:value={user.lastname} label="Nachname" />
|
||||
<Input type="date" bind:value={user.birthday} label="Geburtstag" />
|
||||
<Input type="tel" bind:value={user.telephone} label="Telefonnummer" />
|
||||
<Input type="text" bind:value={user.username} label="Spielername" />
|
||||
<Input type="text" bind:value={user.uuid} label="UUID" />
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success" onclick={onSaveButtonClick}>{texts[action!].buttonTitle}</button>
|
||||
<button class="btn btn-error">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -1,24 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { userCreateOrEditPopupState } from './state.ts';
|
||||
import { addUser, fetchUsers } from './actions.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { addUser, fetchUsers } from '@app/admin/users/users.ts';
|
||||
|
||||
// states
|
||||
let usernameFilter = $state<string | null>(null);
|
||||
|
||||
let createPopupOpen = $state(false);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
fetchUsers({ username: usernameFilter });
|
||||
});
|
||||
|
||||
// callbacks
|
||||
async function onNewUserButtonClick() {
|
||||
$userCreateOrEditPopupState = {
|
||||
create: {
|
||||
onUpdate: addUser
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@ -27,8 +21,34 @@
|
||||
<Input bind:value={usernameFilter} label="Username" />
|
||||
</fieldset>
|
||||
<div class="divider my-1"></div>
|
||||
<button class="btn btn-soft w-full" onclick={() => onNewUserButtonClick()}>
|
||||
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||
<Icon icon="heroicons:plus-16-solid" />
|
||||
<span>Neuer Nutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Nutzer erstellen',
|
||||
submitButtonTitle: 'Erstellen',
|
||||
confirmPopupTitle: 'Nutzer erstellen?',
|
||||
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
|
||||
}}
|
||||
target={null}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
|
||||
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
|
||||
{ key: 'telephone', type: 'tel', label: 'Telefonnummer', default: null }
|
||||
],
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
|
||||
{ key: 'uuid', type: 'text', label: 'UUID', default: null }
|
||||
]
|
||||
]}
|
||||
onSubmit={addUser}
|
||||
bind:open={createPopupOpen}
|
||||
/>
|
||||
|
@ -1,56 +1,65 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
||||
import type { User } from './types.ts';
|
||||
import { userCreateOrEditPopupState, users } from './state.ts';
|
||||
import { editUser } from './actions.ts';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { deleteUser, editUser, type User, users } from '@app/admin/users/users.ts';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
|
||||
// callbacks
|
||||
async function onUserEditButtonClick(user: User) {
|
||||
$userCreateOrEditPopupState = {
|
||||
edit: {
|
||||
user: user,
|
||||
onUpdate: editUser
|
||||
}
|
||||
// state
|
||||
let editPopupUser = $state(null);
|
||||
let editPopupOpen = $derived(!!editPopupUser);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
if (!editPopupOpen) editPopupUser = null;
|
||||
});
|
||||
|
||||
// callback
|
||||
function onUserDelete(user: User) {
|
||||
$confirmPopupState = {
|
||||
title: 'Nutzer löschen?',
|
||||
message: 'Soll der Nutzer wirklich gelöscht werden?',
|
||||
onConfirm: () => deleteUser(user)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr data={users}>
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
<SortableTh style="width: 15%" key="firstname">Vorname</SortableTh>
|
||||
<SortableTh style="width: 15%" key="lastname">Nachname</SortableTh>
|
||||
<SortableTh style="width: 5%" key="birthday">Geburtstag</SortableTh>
|
||||
<SortableTh style="width: 12%" key="phone">Telefon</SortableTh>
|
||||
<SortableTh style="width: 20%" key="username">Username</SortableTh>
|
||||
<SortableTh style="width: 23%">UUID</SortableTh>
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $users as user, i (user.id)}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td>{i + 1}</td>
|
||||
<td>{user.firstname}</td>
|
||||
<td>{user.lastname}</td>
|
||||
<td>{user.birthday}</td>
|
||||
<td>{user.telephone}</td>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.uuid}</td>
|
||||
<td>
|
||||
<button class="cursor-pointer" onclick={() => onUserEditButtonClick(user)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable
|
||||
data={users}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'firstname', label: 'Vorname', width: 15, sortable: true },
|
||||
{ key: 'lastname', label: 'Nachname', width: 15, sortable: true },
|
||||
{ key: 'birthday', label: 'Geburtstag', width: 5, sortable: true },
|
||||
{ key: 'telephone', label: 'Telefon', width: 12, sortable: true },
|
||||
{ key: 'username', label: 'Username', width: 20, sortable: true },
|
||||
{ key: 'uuid', label: 'UUID', width: 23 }
|
||||
]}
|
||||
onEdit={(user) => (editPopupUser = user)}
|
||||
onDelete={onUserDelete}
|
||||
/>
|
||||
|
||||
<CreateOrEditPopup />
|
||||
<CrudPopup
|
||||
texts={{
|
||||
title: 'Nutzer bearbeiten',
|
||||
submitButtonTitle: 'Speichern',
|
||||
confirmPopupTitle: 'Änderungen speichern?',
|
||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||
}}
|
||||
target={editPopupUser}
|
||||
keys={[
|
||||
[
|
||||
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
|
||||
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
|
||||
],
|
||||
[
|
||||
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
|
||||
{ key: 'telephone', type: 'tel', label: 'Telefonnummer' }
|
||||
],
|
||||
[
|
||||
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
|
||||
{ key: 'uuid', type: 'text', label: 'UUID' }
|
||||
]
|
||||
]}
|
||||
onSubmit={editUser}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
|
@ -1,6 +0,0 @@
|
||||
import type { UserCreateOrEditPopupState, Users } from './types.ts';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const users = writable<Users>([]);
|
||||
|
||||
export const userCreateOrEditPopupState = writable<UserCreateOrEditPopupState>(null);
|
@ -1,9 +0,0 @@
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||
export type User = Users[0];
|
||||
|
||||
export type UserCreateOrEditPopupState =
|
||||
| { create: { onUpdate: (user: User) => void } }
|
||||
| { edit: { user: User; onUpdate: (user: User) => void } }
|
||||
| null;
|
@ -1,8 +1,16 @@
|
||||
import { actions } from 'astro:actions';
|
||||
import { users } from './state.ts';
|
||||
import type { User } from './types.ts';
|
||||
import { writable } from 'svelte/store';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
|
||||
|
||||
// types
|
||||
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||
export type User = Users[0];
|
||||
|
||||
// state
|
||||
export const users = writable<Users>([]);
|
||||
|
||||
// actions
|
||||
export async function fetchUsers(options?: { username?: string | null }) {
|
||||
const { data, error } = await actions.user.users({ username: options?.username });
|
||||
if (error) {
|
||||
@ -27,10 +35,7 @@ export async function addUser(user: User) {
|
||||
return;
|
||||
}
|
||||
|
||||
users.update((old) => {
|
||||
old.push(Object.assign(user, { id: data.id }));
|
||||
return old;
|
||||
});
|
||||
addToWritableArray(users, Object.assign(user, { id: data.id }));
|
||||
}
|
||||
|
||||
export async function editUser(user: User) {
|
||||
@ -48,9 +53,15 @@ export async function editUser(user: User) {
|
||||
return;
|
||||
}
|
||||
|
||||
users.update((old) => {
|
||||
const index = old.findIndex((a) => a.id == user.id);
|
||||
old[index] = user;
|
||||
return old;
|
||||
});
|
||||
updateWritableArray(users, user, (t) => t.id == user.id);
|
||||
}
|
||||
|
||||
export async function deleteUser(user: User) {
|
||||
const { error } = await actions.user.deleteUser({ id: user.id });
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFromWritableArray(users, (t) => t.id == user.id);
|
||||
}
|
@ -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';
|
||||
@ -12,11 +12,15 @@
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// html bindings
|
||||
let navElem: HTMLDivElement;
|
||||
|
||||
// states
|
||||
let navPaths = $state([
|
||||
{
|
||||
name: 'Startseite',
|
||||
sprite: MenuHome.src,
|
||||
href: ``,
|
||||
href: '',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
@ -44,9 +48,9 @@
|
||||
active: false
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
sprite: MenuTeam.src,
|
||||
href: 'team',
|
||||
name: 'Admins',
|
||||
sprite: MenuAdmins.src,
|
||||
href: 'admins',
|
||||
active: false
|
||||
}
|
||||
]);
|
||||
@ -56,20 +60,22 @@
|
||||
let isOpen = $state(false);
|
||||
let windowHeight = $state(0);
|
||||
|
||||
// lifecycle
|
||||
$effect(() => {
|
||||
localStorage.setItem('showMenuPermanent', `${showMenuPermanent}`);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
new MutationObserver(() => {
|
||||
for (let i = 0; i < navPaths.length; i++) {
|
||||
console.log(navPaths[i].href, window.location.pathname);
|
||||
navPaths[i].active = new URL(navPaths[i].href).pathname === window.location.pathname;
|
||||
}
|
||||
}).observe(document.head, { childList: true });
|
||||
updateActiveNavPath();
|
||||
new MutationObserver(updateActiveNavPath).observe(document.head, { childList: true });
|
||||
});
|
||||
|
||||
let navElem: HTMLDivElement;
|
||||
// functions
|
||||
function updateActiveNavPath() {
|
||||
for (let i = 0; i < navPaths.length; i++) {
|
||||
navPaths[i].active = new URL(document.baseURI).pathname + navPaths[i].href === window.location.pathname;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Steve from '@assets/img/steve.png';
|
||||
import Team from '@components/website/Team.svelte';
|
||||
import type { GetDeathsRes } from '@db/schema/death.ts';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
|
||||
interface Props {
|
||||
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
deaths: GetDeathsRes;
|
||||
}
|
||||
|
||||
const { teams, deaths }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
|
||||
<table class="table table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th>Spieler 1</th>
|
||||
<th>Spieler 2</th>
|
||||
<th>Kills</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each teams as team (team.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<Team name={team.name} color={team.color} />
|
||||
</td>
|
||||
<td class="max-w-9 overflow-ellipsis">
|
||||
{#if team.memberOne.id}
|
||||
<div class="flex items-center gap-x-2">
|
||||
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
|
||||
<span
|
||||
class="text-xs sm:text-md"
|
||||
class:line-through={deaths.find((d) => d.deadUserId === team.memberOne.id)}
|
||||
>{team.memberOne.username}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if team.memberTwo.id}
|
||||
<div class="flex items-center gap-x-2">
|
||||
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
|
||||
<span
|
||||
class="text-xs sm:text-md"
|
||||
class:line-through={deaths.find((d) => d.deadUserId === team.memberTwo.id)}
|
||||
>{team.memberTwo.username}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs sm:text-md">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
149
src/app/website/index/Teams.svelte
Normal file
149
src/app/website/index/Teams.svelte
Normal file
@ -0,0 +1,149 @@
|
||||
<script lang="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: 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 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 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="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">
|
||||
<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>
|
||||
<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">
|
||||
{team.memberOne.kills.length + team.memberTwo.kills.length}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
77
src/app/website/report/AdversarySearch.svelte
Normal file
77
src/app/website/report/AdversarySearch.svelte
Normal 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
|
||||
>
|
178
src/app/website/report/Dropzone.svelte
Normal file
178
src/app/website/report/Dropzone.svelte
Normal 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>
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { registeredPopupState } from '@components/website/signup/RegisteredPopup.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 </span>
|
||||
<span class="inline-flex rounded-sm w-3 h-3" style="background-color: {$registeredPopupState?.teamColor}"></span>
|
||||
<span>{$registeredPopupState?.team}</span>
|
||||
<span> für Varo 4 registriert</span>. Spielstart ist am
|
||||
<span> 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 />
|
@ -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;
|
@ -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
|
38
src/app/website/signup/TeamPopup.svelte
Normal file
38
src/app/website/signup/TeamPopup.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<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;
|
||||
|
||||
const cancel = teamPopupOpen.subscribe((value) => {
|
||||
if (value) modal.show();
|
||||
else form?.reset();
|
||||
});
|
||||
|
||||
onDestroy(cancel);
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => ($teamPopupOpen = false)}>
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<form method="dialog" bind:this={form} onsubmit={() => ($teamPopupName = form.teamName.value)}>
|
||||
<h3 class="text-lg font-geist">Team erstellen</h3>
|
||||
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt. 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>
|
||||
</dialog>
|
1
src/assets/img/crown.svg
Normal file
1
src/assets/img/crown.svg
Normal 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 |
BIN
src/assets/img/logo-512.webp
Normal file
BIN
src/assets/img/logo-512.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
@ -1,205 +0,0 @@
|
||||
---
|
||||
import astroLogo from '@assets/astro.svg';
|
||||
import background from '@assets/background.svg';
|
||||
---
|
||||
|
||||
<div id="container">
|
||||
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||
<main>
|
||||
<section id="hero">
|
||||
<a href="https://astro.build"><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a>
|
||||
<h1>
|
||||
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||
</h1>
|
||||
<section id="links">
|
||||
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||
<a href="https://astro.build/chat"
|
||||
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||
fill="#111827"></path></svg
|
||||
>
|
||||
<h2>What's New in Astro 5.0?</h2>
|
||||
<p>
|
||||
From content layers to server islands, click to learn more about the new features and improvements in Astro 5.0
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
#container {
|
||||
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
#links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#links a:hover {
|
||||
color: rgb(78, 80, 86);
|
||||
}
|
||||
|
||||
#links a svg {
|
||||
height: 1em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
color: white;
|
||||
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#links a.button:hover {
|
||||
color: rgb(230, 230, 230);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||
font-weight: normal;
|
||||
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1em;
|
||||
font-weight: normal;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
#news {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: 300px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
#news:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 368px) {
|
||||
#news {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: block;
|
||||
padding-top: 10%;
|
||||
}
|
||||
|
||||
#links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
#news {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 2.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
257
src/components/admin/popup/CrudPopup.svelte
Normal file
257
src/components/admin/popup/CrudPopup.svelte
Normal file
@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Password from '@components/input/Password.svelte';
|
||||
import BitBadge from '@components/input/BitBadge.svelte';
|
||||
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||
import Checkbox from '@components/input/Checkbox.svelte';
|
||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||
|
||||
// html bindings
|
||||
let modal: HTMLDialogElement;
|
||||
let modalForm: HTMLFormElement;
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
texts: {
|
||||
title: string;
|
||||
submitButtonTitle: string;
|
||||
confirmPopupTitle: string;
|
||||
confirmPopupMessage: string;
|
||||
};
|
||||
|
||||
target: T | null;
|
||||
keys: Key<any>[][];
|
||||
|
||||
onSubmit: (target: T) => void;
|
||||
onClose?: () => void;
|
||||
|
||||
open: boolean;
|
||||
}
|
||||
type Key<T extends KeyInputType> = {
|
||||
key: string;
|
||||
type: T | null;
|
||||
label: string;
|
||||
default?: any;
|
||||
options?: KeyInputTypeOptions<T>;
|
||||
};
|
||||
type KeyInputType =
|
||||
| 'bit-badge'
|
||||
| 'checkbox'
|
||||
| 'color'
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'tel'
|
||||
| 'team-search'
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'user-search';
|
||||
type KeyInputTypeOptions<T extends KeyInputType> = {
|
||||
['bit-badge']: {
|
||||
available: Record<number, string>;
|
||||
};
|
||||
['checkbox']: {};
|
||||
['color']: {};
|
||||
['date']: {};
|
||||
['datetime-local']: {};
|
||||
['number']: {};
|
||||
['password']: {};
|
||||
['tel']: {};
|
||||
['team-search']: {};
|
||||
['text']: {};
|
||||
['textarea']: {
|
||||
rows?: boolean;
|
||||
};
|
||||
['user-search']: {
|
||||
mustMatch?: boolean;
|
||||
};
|
||||
}[T] & {
|
||||
convert?: (value: any) => any;
|
||||
validate?: (value: any) => boolean;
|
||||
required?: boolean;
|
||||
dynamicWidth?: boolean;
|
||||
};
|
||||
|
||||
// input
|
||||
let { texts, target, keys, onSubmit, onClose, open = $bindable() }: Props<any> = $props();
|
||||
|
||||
onInit();
|
||||
|
||||
// state
|
||||
let submitEnabled = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
onInit();
|
||||
modal.show();
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (target == null) onInit();
|
||||
updateSubmitEnabled();
|
||||
});
|
||||
|
||||
// function
|
||||
function onInit() {
|
||||
if (target == null) target = {};
|
||||
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.default !== undefined && target[k.key] === undefined)
|
||||
target[k.key] = k.options?.convert ? k.options.convert(k.default) : k.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitEnabled() {
|
||||
submitEnabled = false;
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
submitEnabled = true;
|
||||
}
|
||||
|
||||
// callbacks
|
||||
function onBindChange(key: string, value: any, options?: KeyInputTypeOptions<any>) {
|
||||
target[key] = options?.convert ? options.convert(value) : value;
|
||||
|
||||
updateSubmitEnabled();
|
||||
}
|
||||
|
||||
async function onSaveButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
$confirmPopupState = {
|
||||
title: texts.confirmPopupTitle,
|
||||
message: texts.confirmPopupMessage,
|
||||
onConfirm: () => {
|
||||
modalForm.submit();
|
||||
onSubmit(target);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCancelButtonClick(e: Event) {
|
||||
e.preventDefault();
|
||||
modalForm.submit();
|
||||
}
|
||||
|
||||
function onModalClose() {
|
||||
setTimeout(() => {
|
||||
open = false;
|
||||
target = null;
|
||||
modalForm.reset();
|
||||
onClose?.();
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
|
||||
<div class="space-y-5">
|
||||
<h3 class="text-xl font-geist font-bold">{texts.title}</h3>
|
||||
<div class="w-full flex flex-col">
|
||||
{#each keys as key (key)}
|
||||
<div
|
||||
class="grid grid-flow-col gap-4"
|
||||
class:grid-cols-1={key.length === 1}
|
||||
class:grid-cols-2={key.length === 2}
|
||||
>
|
||||
{#each key as k (k)}
|
||||
{#if k.type === 'color' || k.type === 'date' || k.type === 'datetime-local' || k.type === 'number' || k.type === 'tel' || k.type === 'text'}
|
||||
<Input
|
||||
type={k.type}
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'textarea'}
|
||||
<Textarea
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
rows={k.options?.rows}
|
||||
dynamicWidth={k.options?.dynamicWidth}
|
||||
/>
|
||||
{:else if k.type === 'checkbox'}
|
||||
<Checkbox
|
||||
bind:checked={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'user-search'}
|
||||
<UserSearch
|
||||
bind:value={
|
||||
() =>
|
||||
target[k.key] ? (target[k.key]['username'] ?? null) : k.default ? k.default['username'] : null,
|
||||
(v) =>
|
||||
k.options.mustMatch
|
||||
? onBindChange(k.key, { id: null, username: null }, k.options)
|
||||
: onBindChange(k.key, { id: null, username: v }, k.options)
|
||||
}
|
||||
onSubmit={(user) => onBindChange(k.key, user, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
mustMatch={k.options?.mustMatch}
|
||||
/>
|
||||
{:else if k.type === 'team-search'}
|
||||
<TeamSearch
|
||||
bind:value={
|
||||
() => (target[k.key] ? (target[k.key]['name'] ?? null) : k.default ? k.default['name'] : null),
|
||||
(v) =>
|
||||
k.options.mustMatch
|
||||
? onBindChange(k.key, { id: null, name: null, color: null }, k.options)
|
||||
: onBindChange(k.key, { id: null, name: v, color: null }, k.options)
|
||||
}
|
||||
onSubmit={(team) => onBindChange(k.key, team, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
mustMatch={k.options?.mustMatch}
|
||||
/>
|
||||
{:else if k.type === 'password'}
|
||||
<Password
|
||||
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||
label={k.label}
|
||||
required={k.options?.required}
|
||||
/>
|
||||
{:else if k.type === 'bit-badge'}
|
||||
<BitBadge
|
||||
available={k.options?.available}
|
||||
bind:value={
|
||||
() => (target[k.key] ? (target[k.key]['name'] ?? target[k.key]) : k.default),
|
||||
(v) => onBindChange(k.key, v, k.options)
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
class:disabled={!submitEnabled}
|
||||
disabled={!submitEnabled}
|
||||
onclick={onSaveButtonClick}>{texts.submitButtonTitle}</button
|
||||
>
|
||||
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
@ -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
|
||||
@ -20,8 +20,8 @@
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let inputValue = $state(value);
|
||||
let suggestions = $state<string[]>([]);
|
||||
let inputValue = $derived(value);
|
||||
let suggestions = $state<{ name: string; value: string }[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
// callbacks
|
||||
@ -34,11 +34,11 @@
|
||||
|
||||
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?.(value);
|
||||
onSubmit?.(suggestion);
|
||||
} else if (!mustMatch) {
|
||||
value = inputValue;
|
||||
matched = false;
|
||||
@ -49,10 +49,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
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?.(value);
|
||||
onSubmit?.(suggestion);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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}
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
81
src/components/admin/table/DataTable.svelte
Normal file
81
src/components/admin/table/DataTable.svelte
Normal file
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props<T> {
|
||||
data: Writable<T[]>;
|
||||
|
||||
count?: boolean;
|
||||
keys: {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
transform?: Snippet<[T]>;
|
||||
}[];
|
||||
|
||||
onClick?: (t: T) => void;
|
||||
onEdit?: (t: T) => void;
|
||||
onDelete?: (t: T) => void;
|
||||
}
|
||||
|
||||
// input
|
||||
let { data, count, keys, onClick, onEdit, onDelete }: Props<any> = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-screen overflow-x-auto">
|
||||
<table class="table table-pin-rows">
|
||||
<thead>
|
||||
<SortableTr {data}>
|
||||
{#if count}
|
||||
<SortableTh style="width: 5%">#</SortableTh>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<SortableTh style={key.width ? `width: ${key.width}%` : undefined} key={key.sortable ? key.key : undefined}
|
||||
>{key.label}</SortableTh
|
||||
>
|
||||
{/each}
|
||||
{#if onEdit || onDelete}
|
||||
<SortableTh style="width: 5%"></SortableTh>
|
||||
{/if}
|
||||
</SortableTr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $data as d, i (d)}
|
||||
<tr class="hover:bg-base-200" onclick={() => onClick?.(d)}>
|
||||
{#if count}
|
||||
<td>{i + 1}</td>
|
||||
{/if}
|
||||
{#each keys as key (key.key)}
|
||||
<td>
|
||||
{#if key.transform}
|
||||
{@render key.transform(getObjectEntryByKey(key.key, d))}
|
||||
{:else}
|
||||
{getObjectEntryByKey(key.key, d)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if onEdit || onDelete}
|
||||
<td>
|
||||
{#if onEdit}
|
||||
<button class="cursor-pointer" onclick={() => onEdit(d)}>
|
||||
<Icon icon="heroicons:pencil-square" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button class="cursor-pointer" onclick={() => onDelete(d)}>
|
||||
<Icon icon="heroicons:trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { type Writable, writable } from 'svelte/store';
|
||||
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
@ -21,8 +22,8 @@
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
data.update((old) => {
|
||||
old.sort((a, b) => {
|
||||
let entryA = getDataEntryByKey(key, a);
|
||||
let entryB = getDataEntryByKey(key, b);
|
||||
let entryA = getObjectEntryByKey(key, a);
|
||||
let entryB = getObjectEntryByKey(key, b);
|
||||
|
||||
if (entryA === undefined || entryB === undefined) return 0;
|
||||
|
||||
@ -40,16 +41,6 @@
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
if ((entry = entry[part]) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr {...restProps}>
|
||||
|
49
src/components/input/BitBadge.svelte
Normal file
49
src/components/input/BitBadge.svelte
Normal file
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
available: Record<number, string>;
|
||||
value: number;
|
||||
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { available, value = $bindable(), readonly }: Props = $props();
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const selected = Number((e.target as HTMLSelectElement).value);
|
||||
|
||||
value |= selected;
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(flag: number) {
|
||||
value &= ~flag;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !readonly}
|
||||
<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={(value & Number(flag)) !== 0}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#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>
|
@ -3,7 +3,7 @@
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
type?: 'color' | 'date' | 'datetime-local' | 'tel' | 'text' | 'email';
|
||||
type?: 'color' | 'date' | 'datetime-local' | 'number' | 'tel' | 'text' | 'email';
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
|
@ -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">
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
// callbacks
|
||||
function onModalClose() {
|
||||
$popupState?.onClose?.();
|
||||
setTimeout(() => ($popupState = null), 300);
|
||||
}
|
||||
</script>
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -1,13 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const { name, color }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="rounded-sm w-3 h-3" style="background-color: {color}"></div>
|
||||
<h3 class="text-xs sm:text-xl">{name}</h3>
|
||||
</div>
|
@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { teamPopupOpen, teamPopupName } from '@components/website/signup/TeamPopup.ts';
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
let form: HTMLFormElement;
|
||||
|
||||
teamPopupOpen.subscribe((value) => {
|
||||
if (value) modal.show();
|
||||
else form?.reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog class="modal" bind:this={modal} onclose={() => ($teamPopupOpen = false)}>
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<form method="dialog" bind:this={form} onsubmit={() => ($teamPopupName = form.teamName.value)}>
|
||||
<h3 class="text-lg font-geist">Team erstellen</h3>
|
||||
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt.</p>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
<span>Teamname <span class="text-red-700">*</span></span>
|
||||
</legend>
|
||||
<input id="teamName" name="teamName" class="input validator" type="text" required />
|
||||
</fieldset>
|
||||
<button class="mt-4 btn btn-neutral">Team registrieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
@ -28,6 +28,13 @@ CREATE TRIGGER IF NOT EXISTS user_username_update AFTER UPDATE ON user
|
||||
END;
|
||||
DELIMITER ;
|
||||
|
||||
-- blocked user
|
||||
CREATE TABLE IF NOT EXISTS blocked_user (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(255) UNIQUE NOT NULL,
|
||||
comment TINYTEXT
|
||||
);
|
||||
|
||||
-- team
|
||||
CREATE TABLE IF NOT EXISTS team (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@ -40,8 +47,8 @@ CREATE TABLE IF NOT EXISTS team (
|
||||
CREATE TABLE IF NOT EXISTS team_member (
|
||||
team_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
FOREIGN KEY (team_id) REFERENCES team(id),
|
||||
FOREIGN KEY (user_id) REFERENCES user(id)
|
||||
FOREIGN KEY (team_id) REFERENCES team(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- team draft
|
||||
@ -49,16 +56,50 @@ CREATE TABLE IF NOT EXISTS team_draft (
|
||||
member_one_name VARCHAR(255) NOT NULL,
|
||||
member_two_name VARCHAR(255) NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
FOREIGN KEY (team_id) REFERENCES team(id)
|
||||
FOREIGN KEY (team_id) REFERENCES team(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 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),
|
||||
FOREIGN KEY (killer_user_id) REFERENCES user(id)
|
||||
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
|
||||
@ -70,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)
|
||||
);
|
||||
|
||||
-- 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),
|
||||
FOREIGN KEY (reported_team_id) REFERENCES team(id)
|
||||
);
|
||||
|
||||
-- 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,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id),
|
||||
FOREIGN KEY (reviewer_id) REFERENCES admin(id),
|
||||
FOREIGN KEY (strike_id) REFERENCES strike(id)
|
||||
strike_reason_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- feedback
|
||||
@ -111,11 +127,11 @@ CREATE TABLE IF NOT EXISTS feedback (
|
||||
url_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
last_changed TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
user_id INT,
|
||||
FOREIGN KEY (user_id) REFERENCES user(id)
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- settings
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
@ -9,7 +9,9 @@ import {
|
||||
type AddAdminReq,
|
||||
addAdmin,
|
||||
type EditAdminReq,
|
||||
editAdmin
|
||||
editAdmin,
|
||||
type DeleteAdminReq,
|
||||
deleteAdmin
|
||||
} from './schema/admin';
|
||||
import {
|
||||
addTeam,
|
||||
@ -28,7 +30,9 @@ import {
|
||||
type EditTeamReq,
|
||||
editTeam,
|
||||
type GetTeamsFullReq,
|
||||
getTeamsFull
|
||||
getTeamsFull,
|
||||
type GetTeamsByUsernameReq,
|
||||
getTeamsByUsername
|
||||
} from './schema/team';
|
||||
import {
|
||||
addTeamDraft,
|
||||
@ -46,6 +50,8 @@ import {
|
||||
type AddTeamMemberReq,
|
||||
deleteTeamMemberByTeamId,
|
||||
type DeleteTeamMemberByTeamIdReq,
|
||||
getTeamMembersByTeamId,
|
||||
type GetTeamMembersByTeamIdReq,
|
||||
teamMember
|
||||
} from './schema/teamMember';
|
||||
import {
|
||||
@ -77,9 +83,13 @@ import {
|
||||
setSettings
|
||||
} from './schema/settings';
|
||||
import {
|
||||
addDeath,
|
||||
type AddDeathReq,
|
||||
addDeath,
|
||||
death,
|
||||
deleteDeath,
|
||||
type DeleteDeathReq,
|
||||
editDeath,
|
||||
type EditDeathReq,
|
||||
getDeathByUserId,
|
||||
type GetDeathByUserIdReq,
|
||||
getDeaths,
|
||||
@ -91,13 +101,51 @@ 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, getStrikeReasons, strikeReason } from '@db/schema/strikeReason.ts';
|
||||
import { getStrikesByTeamId, type GetStrikesByTeamId, strike } from '@db/schema/strike.ts';
|
||||
import {
|
||||
type GetStrikeReasonsReq,
|
||||
getStrikeReasons,
|
||||
strikeReason,
|
||||
type AddStrikeReasonReq,
|
||||
addStrikeReason,
|
||||
type EditStrikeReasonReq,
|
||||
editStrikeReason,
|
||||
type DeleteStrikeReasonReq,
|
||||
deleteStrikeReason
|
||||
} from '@db/schema/strikeReason.ts';
|
||||
import {
|
||||
deleteStrike,
|
||||
type DeleteStrikeReq,
|
||||
editStrike,
|
||||
type EditStrikeReq,
|
||||
getStrikeByReportId,
|
||||
type GetStrikeByReportIdReq,
|
||||
getStrikesByTeamId,
|
||||
type GetStrikesByTeamIdReq,
|
||||
strike
|
||||
} from '@db/schema/strike.ts';
|
||||
import {
|
||||
editReportStatus,
|
||||
type EditReportStatusReq,
|
||||
@ -105,6 +153,26 @@ import {
|
||||
type GetReportStatusReq,
|
||||
reportStatus
|
||||
} from '@db/schema/reportStatus.ts';
|
||||
import {
|
||||
addBlockedUser,
|
||||
type AddBlockedUserReq,
|
||||
getBlockedUsers,
|
||||
type GetBlockedUsersReq,
|
||||
blockedUser,
|
||||
type GetBlockedUserByUuidReq,
|
||||
getBlockedUserByUuid,
|
||||
type EditBlockedUserReq,
|
||||
editBlockedUser,
|
||||
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<{
|
||||
@ -113,8 +181,10 @@ export class Database {
|
||||
teamDraft: typeof teamDraft;
|
||||
teamMember: typeof teamMember;
|
||||
user: typeof user;
|
||||
blockedUser: typeof blockedUser;
|
||||
death: typeof death;
|
||||
report: typeof report;
|
||||
reportAttachment: typeof reportAttachment;
|
||||
reportStatus: typeof reportStatus;
|
||||
strike: typeof strike;
|
||||
strikeReason: typeof strikeReason;
|
||||
@ -139,8 +209,10 @@ export class Database {
|
||||
teamDraft,
|
||||
teamMember,
|
||||
user,
|
||||
blockedUser,
|
||||
death,
|
||||
report,
|
||||
reportAttachment,
|
||||
reportStatus,
|
||||
strike,
|
||||
strikeReason,
|
||||
@ -160,6 +232,7 @@ export class Database {
|
||||
/* admins */
|
||||
addAdmin = (values: AddAdminReq) => addAdmin(this.db, values);
|
||||
editAdmin = (values: EditAdminReq) => editAdmin(this.db, values);
|
||||
deleteAdmin = (values: DeleteAdminReq) => deleteAdmin(this.db, values);
|
||||
getAdmins = (values: GetAdminReq) => getAdmins(this.db, values);
|
||||
existsAdmin = (values: ExistsAdminReq) => existsAdmin(this.db, values);
|
||||
|
||||
@ -173,6 +246,13 @@ export class Database {
|
||||
getUserByUsername = (values: GetUserByUsernameReq) => getUserByUsername(this.db, values);
|
||||
getUsersByUuid = (values: GetUsersByUuidReq) => getUsersByUuid(this.db, values);
|
||||
|
||||
/* user blocks */
|
||||
addBlockedUser = (values: AddBlockedUserReq) => addBlockedUser(this.db, values);
|
||||
editBlockedUser = (values: EditBlockedUserReq) => editBlockedUser(this.db, values);
|
||||
deleteBlockedUser = (values: DeleteBlockedUserReq) => deleteBlockedUser(this.db, values);
|
||||
getBlockedUserByUuid = (values: GetBlockedUserByUuidReq) => getBlockedUserByUuid(this.db, values);
|
||||
getBlockedUsers = (values: GetBlockedUsersReq) => getBlockedUsers(this.db, values);
|
||||
|
||||
/* team */
|
||||
addTeam = (values: AddTeamReq) => addTeam(this.db, values);
|
||||
editTeam = (values: EditTeamReq) => editTeam(this.db, values);
|
||||
@ -182,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);
|
||||
@ -192,28 +273,49 @@ 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);
|
||||
editReportStatus = (values: EditReportStatusReq) => editReportStatus(this.db, values);
|
||||
|
||||
/* strike reason */
|
||||
addStrikeReason = (values: AddStrikeReasonReq) => addStrikeReason(this.db, values);
|
||||
editStrikeReason = (values: EditStrikeReasonReq) => editStrikeReason(this.db, values);
|
||||
deleteStrikeReason = (values: DeleteStrikeReasonReq) => deleteStrikeReason(this.db, values);
|
||||
getStrikeReasons = (values: GetStrikeReasonsReq) => getStrikeReasons(this.db, values);
|
||||
getStrikesByTeamId = (values: GetStrikesByTeamId) => getStrikesByTeamId(this.db, values);
|
||||
|
||||
/* strikes */
|
||||
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);
|
||||
|
@ -22,6 +22,10 @@ export type EditAdminReq = {
|
||||
permissions: number;
|
||||
};
|
||||
|
||||
export type DeleteAdminReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetAdminReq = {};
|
||||
export type GetAdminRes = Omit<typeof admin.$inferSelect, 'password'>[];
|
||||
|
||||
@ -55,6 +59,10 @@ export async function editAdmin(db: Database, values: EditAdminReq) {
|
||||
.where(eq(admin.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteAdmin(db: Database, values: DeleteAdminReq) {
|
||||
return db.delete(admin).where(eq(admin.id, values.id));
|
||||
}
|
||||
|
||||
export async function getAdmins(db: Database, _values: GetAdminReq): Promise<GetAdminRes> {
|
||||
return db.select({ id: admin.id, username: admin.username, permissions: admin.permissions }).from(admin);
|
||||
}
|
||||
|
58
src/db/schema/blockedUser.ts
Normal file
58
src/db/schema/blockedUser.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type Database = MySql2Database<{ blockedUser: typeof blockedUser }>;
|
||||
|
||||
export const blockedUser = mysqlTable('blocked_user', {
|
||||
id: int('id').primaryKey().autoincrement(),
|
||||
uuid: varchar('uuid', { length: 255 }).unique().notNull(),
|
||||
comment: varchar('comment', { length: 255 })
|
||||
});
|
||||
|
||||
export type AddBlockedUserReq = {
|
||||
uuid: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type EditBlockedUserReq = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type DeleteBlockedUserReq = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetBlockedUserByUuidReq = {
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export type GetBlockedUsersReq = {};
|
||||
|
||||
export async function addBlockedUser(db: Database, values: AddBlockedUserReq) {
|
||||
const bu = await db.insert(blockedUser).values(values).$returningId();
|
||||
|
||||
return bu[0];
|
||||
}
|
||||
|
||||
export async function editBlockedUser(db: Database, values: EditBlockedUserReq) {
|
||||
await db.update(blockedUser).set(values).where(eq(blockedUser.id, values.id));
|
||||
}
|
||||
|
||||
export async function deleteBlockedUser(db: Database, values: DeleteBlockedUserReq) {
|
||||
return db.delete(blockedUser).where(eq(blockedUser.id, values.id));
|
||||
}
|
||||
|
||||
export async function getBlockedUserByUuid(db: Database, values: GetBlockedUserByUuidReq) {
|
||||
const bu = await db.query.blockedUser.findFirst({
|
||||
where: eq(blockedUser.uuid, values.uuid)
|
||||
});
|
||||
|
||||
return bu ?? null;
|
||||
}
|
||||
|
||||
export async function getBlockedUsers(db: Database, _values: GetBlockedUsersReq) {
|
||||
return db.select().from(blockedUser);
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
@ -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,
|
||||
@ -68,11 +85,14 @@ export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
|
||||
content: feedback.content,
|
||||
urlHash: feedback.urlHash,
|
||||
lastChanged: feedback.lastChanged,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
username: user.username
|
||||
})
|
||||
.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)
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
38
src/db/schema/reportAttachment.ts
Normal file
38
src/db/schema/reportAttachment.ts
Normal 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));
|
||||
}
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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> {
|
||||
|
@ -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));
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user