Compare commits
60 Commits
afd3541d4d
...
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 |
@ -6,6 +6,8 @@ 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
|
||||
|
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>
|
||||
|
||||
|
@ -38,6 +38,9 @@ export default defineConfig({
|
||||
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 }),
|
||||
|
@ -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,18 @@ export const report = {
|
||||
};
|
||||
}
|
||||
}),
|
||||
reportAttachments: defineAction({
|
||||
input: z.object({
|
||||
reportId: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
|
||||
|
||||
return {
|
||||
reportAttachments: (await db.getReportAttachments(input)) ?? []
|
||||
};
|
||||
}
|
||||
}),
|
||||
addStrikeReason: defineAction({
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
@ -109,5 +263,29 @@ export const report = {
|
||||
strikeReasons: await db.getStrikeReasons({})
|
||||
};
|
||||
}
|
||||
}),
|
||||
teamNamesByUsername: defineAction({
|
||||
input: z.object({
|
||||
username: z.string().nullish()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const teams = await db.getTeamsByUsername({ username: input.username ?? '', limit: 5 });
|
||||
|
||||
return {
|
||||
teamNames: teams.map((team) => ({ name: team.user.username, value: team.team.name }))
|
||||
};
|
||||
}
|
||||
}),
|
||||
teamNamesByTeamName: defineAction({
|
||||
input: z.object({
|
||||
teamName: z.string().nullish()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const teams = await db.getTeams({ name: input.teamName, limit: 5 });
|
||||
|
||||
return {
|
||||
teamNames: teams.map((team) => ({ name: team.name, value: team.name }))
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
@ -9,10 +9,10 @@ export const signup = {
|
||||
input: z.object({
|
||||
firstname: z.string().trim().min(2),
|
||||
lastname: z.string().trim().min(2),
|
||||
// this will be inaccurate as it is evaluated only once
|
||||
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().trim().nullable(),
|
||||
username: z.string().trim(),
|
||||
@ -66,8 +66,9 @@ export const signup = {
|
||||
});
|
||||
}
|
||||
|
||||
let memberUuid;
|
||||
try {
|
||||
await getJavaUuid(input.teamMember);
|
||||
memberUuid = await getJavaUuid(input.teamMember);
|
||||
} catch (_) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
@ -85,6 +86,15 @@ export const signup = {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (memberUuid) {
|
||||
const blockedUser = await db.getBlockedUserByUuid({ uuid: memberUuid });
|
||||
if (blockedUser) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Dein Mitspieler ist für die Registrierung gesperrt. Bitte suche dir einen anderen Mitspieler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!teamDraft) {
|
||||
// check if a team with the same name already exists
|
||||
|
@ -128,5 +128,53 @@ export const team = {
|
||||
teams: await db.getTeams(input)
|
||||
};
|
||||
}
|
||||
}),
|
||||
addDeath: defineAction({
|
||||
input: z.object({
|
||||
deadUserId: z.number(),
|
||||
killerUserId: z.number().nullish(),
|
||||
message: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
const { id } = await db.addDeath(input);
|
||||
|
||||
return {
|
||||
id: id
|
||||
};
|
||||
}
|
||||
}),
|
||||
editDeath: defineAction({
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
deadUserId: z.number(),
|
||||
killerUserId: z.number().nullish(),
|
||||
message: z.string()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.editDeath(input);
|
||||
}
|
||||
}),
|
||||
deleteDeath: defineAction({
|
||||
input: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
await db.deleteDeath(input);
|
||||
}
|
||||
}),
|
||||
deaths: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
|
||||
|
||||
return {
|
||||
deaths: await db.getDeaths({})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
57
src/actions/tools.ts
Normal file
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
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
@ -4,7 +4,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
|
||||
// consts
|
||||
// consts
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
|
@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Report, ReportStatus, StrikeReasons } from './reports.ts';
|
||||
import { editReport, getReportAttachments, type Report, type ReportStatus, type StrikeReasons } from './reports.ts';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import Select from '@components/input/Select.svelte';
|
||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
// html bindings
|
||||
let previewDialogElem: HTMLDialogElement;
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
@ -17,14 +21,20 @@
|
||||
let { strikeReasons, report }: Props = $props();
|
||||
|
||||
// states
|
||||
let reportedTeam = $state<{ id: number; name: string } | null>(report?.reported ?? null);
|
||||
|
||||
let status = $state<'open' | 'closed' | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let statement = $state<string | null>(null);
|
||||
let strikeReason = $state<string | null>(String(report?.strike?.strikeReasonId ?? null));
|
||||
|
||||
let reportAttachments = $state<{ type: 'image' | 'video'; hash: string }[]>([]);
|
||||
let previewReportAttachment = $state<{ type: 'image' | 'video'; hash: string } | null>(null);
|
||||
|
||||
// consts
|
||||
const strikeReasonValues = strikeReasons.reduce(
|
||||
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
|
||||
{}
|
||||
{ [null]: 'Kein Vergehen' }
|
||||
);
|
||||
|
||||
// lifetime
|
||||
@ -38,6 +48,14 @@
|
||||
notice = reportStatus.notice;
|
||||
statement = reportStatus.statement;
|
||||
});
|
||||
|
||||
getReportAttachments(report).then((value) => {
|
||||
if (value) reportAttachments = value;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (previewReportAttachment) previewDialogElem.show();
|
||||
});
|
||||
|
||||
// callbacks
|
||||
@ -45,43 +63,104 @@
|
||||
$confirmPopupState = {
|
||||
title: 'Änderungen speichern?',
|
||||
message: 'Sollen die Änderungen am Report gespeichert werden?',
|
||||
onConfirm: async () =>
|
||||
editReportStatus(report!, {
|
||||
onConfirm: async () => {
|
||||
if (reportedTeam?.id != report?.reported?.id) {
|
||||
report!.reported = reportedTeam;
|
||||
await editReport(report!);
|
||||
}
|
||||
await editReportStatus(report!, {
|
||||
status: status,
|
||||
notice: notice,
|
||||
statement: statement,
|
||||
strikeId: null
|
||||
} as ReportStatus)
|
||||
strikeReasonId: Number(strikeReason)
|
||||
} as ReportStatus);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onCopyPublicLink(urlHash: string) {
|
||||
navigator.clipboard.writeText(`${document.baseURI}report/${urlHash}`);
|
||||
document.activeElement?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-2"
|
||||
hidden={report === null}
|
||||
>
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (report = null)}>✕</button>
|
||||
<div class="absolute right-2 top-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-circle btn-ghost"><Icon icon="heroicons:share" /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul tabindex="0" class="menu dropdown-content bg-base-100 rounded-box z-1 p-2 shadow-sm w-max">
|
||||
<li><button onclick={() => onCopyPublicLink(report?.urlHash)}>Öffentlichen Report Link kopieren</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={() => (report = null)}>✕</button>
|
||||
</div>
|
||||
<div class="w-[34rem]">
|
||||
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
|
||||
<TeamSearch value={report?.reported?.name} label="Reportetes Team" />
|
||||
<Textarea bind:value={notice} label="Interne Notizen" rows={8} />
|
||||
<TeamSearch value={report?.reported?.name} label="Reportetes Team" onSubmit={(team) => (reportedTeam = team)} />
|
||||
<Textarea bind:value={notice} label="Interne Notizen" rows={10} />
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="w-full">
|
||||
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
|
||||
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} />
|
||||
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={9} />
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Anhänge</legend>
|
||||
<div class="h-16.5 rounded border border-dashed flex">
|
||||
{#each reportAttachments as reportAttachment (reportAttachment.hash)}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="cursor-zoom-in" onclick={() => (previewReportAttachment = reportAttachment)}>
|
||||
{#if reportAttachment.type === 'image'}
|
||||
<img
|
||||
src={location.pathname + '/attachment/' + reportAttachment.hash}
|
||||
alt={reportAttachment.hash}
|
||||
class="w-16 h-16"
|
||||
/>
|
||||
{:else if reportAttachment.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={location.pathname + '/attachment/' + reportAttachment.hash} class="w-16 h-16"></video>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
<div class="flex flex-col w-[42rem]">
|
||||
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} />
|
||||
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={7} />
|
||||
<Select
|
||||
bind:value={status}
|
||||
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
|
||||
defaultValue="Unbearbeitet"
|
||||
label="Bearbeitungsstatus"
|
||||
dynamicWidth
|
||||
/>
|
||||
<Select bind:value={status} values={strikeReasonValues} defaultValue="" label="Vergehen" dynamicWidth></Select>
|
||||
<Select bind:value={strikeReason} values={strikeReasonValues} label="Vergehen" dynamicWidth></Select>
|
||||
<div class="divider mt-0 mb-2"></div>
|
||||
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
class="modal"
|
||||
bind:this={previewDialogElem}
|
||||
onclose={() => setTimeout(() => (previewReportAttachment = null), 300)}
|
||||
>
|
||||
<div class="modal-box">
|
||||
{#if previewReportAttachment?.type === 'image'}
|
||||
<img src={location.pathname + '/attachment/' + previewReportAttachment.hash} alt={previewReportAttachment.hash} />
|
||||
{:else if previewReportAttachment?.type === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={location.pathname + '/attachment/' + previewReportAttachment.hash} controls></video>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
|
||||
<button class="absolute top-3 right-3 btn btn-circle">✕</button>
|
||||
<button class="!cursor-default">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
@ -4,6 +4,15 @@
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import { type StrikeReasons, getStrikeReasons, reports } from '@app/admin/reports/reports.ts';
|
||||
|
||||
// consts
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// states
|
||||
let strikeReasons = $state<StrikeReasons>([]);
|
||||
let activeReport = $state<Report | null>(null);
|
||||
@ -14,6 +23,18 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet date(value: string)}
|
||||
{dateFormat.format(new Date(value))}
|
||||
{/snippet}
|
||||
|
||||
{#snippet status(value: null | 'open' | 'closed')}
|
||||
{#if value === 'open'}
|
||||
<p>In Bearbeitung</p>
|
||||
{:else if value === 'closed'}
|
||||
<p>Bearbeitet</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={reports}
|
||||
count={true}
|
||||
@ -21,8 +42,8 @@
|
||||
{ key: 'reason', label: 'Grund' },
|
||||
{ key: 'reporter.name', label: 'Report Team' },
|
||||
{ key: 'reported.name', label: 'Reportetes Team' },
|
||||
{ key: 'createdAt', label: 'Datum' },
|
||||
{ key: 'report.status?.status', label: 'Bearbeitungsstatus' }
|
||||
{ key: 'createdAt', label: 'Datum', transform: date },
|
||||
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
|
||||
]}
|
||||
onClick={(report) => (activeReport = report)}
|
||||
/>
|
||||
|
@ -61,7 +61,7 @@
|
||||
key: 'createdAt',
|
||||
type: 'checkbox',
|
||||
label: 'Report kann bearbeitet werden',
|
||||
options: { convert: (v) => (v ? new Date().toISOString() : null) }
|
||||
options: { convert: (v) => (v ? null : new Date().toISOString()) }
|
||||
}
|
||||
]
|
||||
]}
|
||||
|
@ -9,7 +9,7 @@ export type Report = Reports[0];
|
||||
export type ReportStatus = Exclude<
|
||||
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
|
||||
null
|
||||
>;
|
||||
> & { strikeReasonId: number | null };
|
||||
|
||||
export type StrikeReasons = Exclude<
|
||||
ActionReturnType<typeof actions.report.strikeReasons>['data'],
|
||||
@ -34,7 +34,7 @@ export async function addReport(report: Report) {
|
||||
const { data, error } = await actions.report.addReport({
|
||||
reason: report.reason,
|
||||
body: report.body,
|
||||
createdAt: report.createdAt,
|
||||
createdAt: report.createdAt as unknown as string,
|
||||
reporter: report.reporter.id,
|
||||
reported: report.reported?.id ?? null
|
||||
});
|
||||
@ -59,13 +59,24 @@ export async function getReportStatus(report: Report) {
|
||||
return data.reportStatus;
|
||||
}
|
||||
|
||||
export async function editReport(report: Report) {
|
||||
const { error } = await actions.report.editReport({
|
||||
reportId: report.id,
|
||||
reported: report.reported?.id ?? null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function editReportStatus(report: Report, reportStatus: ReportStatus) {
|
||||
const { error } = await actions.report.editReportStatus({
|
||||
reportId: report.id,
|
||||
status: reportStatus.status,
|
||||
notice: reportStatus.notice,
|
||||
statement: reportStatus.statement,
|
||||
strikeId: reportStatus.strikeId
|
||||
strikeReasonId: reportStatus.strikeReasonId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@ -73,6 +84,18 @@ export async function editReportStatus(report: Report, reportStatus: ReportStatu
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReportAttachments(report: Report) {
|
||||
const { data, error } = await actions.report.reportAttachments({
|
||||
reportId: report.id
|
||||
});
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
return data.reportAttachments;
|
||||
}
|
||||
|
||||
export async function getStrikeReasons() {
|
||||
const { data, error } = await actions.report.strikeReasons();
|
||||
if (error) {
|
||||
|
@ -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);
|
||||
|
@ -33,7 +33,8 @@
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'name', label: 'Name', width: 20 },
|
||||
{ key: 'weight', label: 'Gewichtung', width: 70, sortable: true }
|
||||
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
|
||||
{ key: 'id', label: 'Id', width: 20 }
|
||||
]}
|
||||
onDelete={onBlockedUserDelete}
|
||||
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}
|
||||
|
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,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { addTeam, deleteTeam, type Team, teams } from '@app/admin/teams/teams.ts';
|
||||
import { deleteTeam, editTeam, type Team, teams } from '@app/admin/teams/teams.ts';
|
||||
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||
@ -27,14 +27,18 @@
|
||||
<div class="rounded-sm w-3 h-3" style="background-color: {value}"></div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet signedUp(value: { id?: number; username: string })}
|
||||
<span class={{ 'text-base-content/50': value.id == null }}>{value.username}</span>
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
data={teams}
|
||||
count={true}
|
||||
keys={[
|
||||
{ key: 'color', label: 'Farbe', width: 5, transform: color },
|
||||
{ key: 'name', label: 'Name', width: 25 },
|
||||
{ key: 'memberOne.username', label: 'Spieler 1', width: 30 },
|
||||
{ key: 'memberTwo.username', label: 'Spieler 2', width: 30 }
|
||||
{ key: 'memberOne', label: 'Spieler 1', width: 30, transform: signedUp },
|
||||
{ key: 'memberTwo', label: 'Spieler 2', width: 30, transform: signedUp }
|
||||
]}
|
||||
onEdit={(team) => (editPopupTeam = team)}
|
||||
onDelete={onTeamDelete}
|
||||
@ -78,6 +82,6 @@
|
||||
}
|
||||
]
|
||||
]}
|
||||
onSubmit={addTeam}
|
||||
onSubmit={editTeam}
|
||||
bind:open={editPopupOpen}
|
||||
/>
|
||||
|
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,100 +1,146 @@
|
||||
<script lang="ts">
|
||||
import Skeleton from '@assets/img/steve.png';
|
||||
import type { GetDeathsRes } from '@db/schema/death.ts';
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import type { db } from '@db/database.ts';
|
||||
import crown from '@assets/img/crown.svg';
|
||||
|
||||
interface Props {
|
||||
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
deaths: GetDeathsRes;
|
||||
deaths: Awaited<ReturnType<typeof db.getDeaths>>;
|
||||
}
|
||||
|
||||
const { teams, deaths }: Props = $props();
|
||||
|
||||
const entries = teams.map((team) => ({
|
||||
...team,
|
||||
memberOne:
|
||||
team.memberOne.id != null
|
||||
? {
|
||||
...team.memberOne,
|
||||
dead: deaths.findIndex((d) => d.deadUserId === team.memberOne.id) !== -1
|
||||
}
|
||||
: undefined,
|
||||
memberTwo:
|
||||
team.memberTwo.id != null
|
||||
? {
|
||||
...team.memberTwo,
|
||||
dead: deaths.findIndex((d) => d.deadUserId === team.memberTwo.id) !== -1
|
||||
}
|
||||
: undefined,
|
||||
kills: deaths.filter((d) => d.killerUserId === team.memberOne.id || d.killerUserId === team.memberTwo.id).length
|
||||
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 aAllowed = !!a.memberOne && !!a.memberTwo && !(a.memberOne.dead && a.memberTwo.dead);
|
||||
const bAllowed = !!b.memberOne && !!b.memberTwo && !(b.memberOne.dead && b.memberTwo.dead);
|
||||
if (!aAllowed && !bAllowed) {
|
||||
return 0;
|
||||
} else if (!aAllowed || !bAllowed) {
|
||||
return (bAllowed as unknown as number) - (aAllowed as unknown as number);
|
||||
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 b.kills - a.kills;
|
||||
return bBothKills - aBothKills;
|
||||
});
|
||||
|
||||
const aliveTeams = entries.reduce(
|
||||
(prev, curr) =>
|
||||
prev + Number(curr.memberOne.id && curr.memberTwo.id && (!curr.memberOne.dead || !curr.memberTwo.dead)),
|
||||
0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
|
||||
<table class="table table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th>Spieler 1</th>
|
||||
<th>Spieler 2</th>
|
||||
<th>Kills</th>
|
||||
<th style="width: 30%">Team</th>
|
||||
<th style="width: 30%">Spieler 1</th>
|
||||
<th style="width: 30%">Spieler 2</th>
|
||||
<th style="width: 10%">Kills</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as team (team.id)}
|
||||
{@const teamSignedUp = !!team.memberOne.id && !!team.memberTwo.id}
|
||||
{@const teamDead = !!team.memberOne.dead && !!team.memberTwo.dead}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="rounded-sm w-3 h-3" style="background-color: {team.color}"></div>
|
||||
<h3 class="text-xs sm:text-xl" class:text-red-200={!team.memberOne}>
|
||||
<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 !team.memberOne || !team.memberTwo}
|
||||
{#if !teamSignedUp}
|
||||
<span>Team unvollständig</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="max-w-9 overflow-ellipsis">
|
||||
{#if team.memberOne}
|
||||
<div class="flex items-center gap-x-2">
|
||||
<img
|
||||
class="w-4 h-4 pixelated"
|
||||
src={team.memberOne.dead ? Skeleton.src : `https://mc-heads.net/head/${team.memberOne.username}/8`}
|
||||
alt="head"
|
||||
/>
|
||||
<span class="text-xs sm:text-md" class:line-through={team.memberOne.dead}
|
||||
>{team.memberOne.username}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-x-2 w-max tooltip">
|
||||
{#if team.memberOne.kills.length > 0 || team.memberOne.dead}
|
||||
<div class="tooltip-content text-left space-y-1">
|
||||
{#each team.memberOne.kills as kill (kill.killed.id)}
|
||||
<p>🔪 {kill.killed.username}</p>
|
||||
{/each}
|
||||
{#if team.memberOne.dead}
|
||||
<p class="mt-2 first:mt-0">{team.memberOne.dead.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if team.memberOne.id != null}
|
||||
<div class="relative">
|
||||
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberOne.username}/8" alt="head" />
|
||||
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
|
||||
<div class="absolute -top-1.25 -right-1.25">
|
||||
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-xs sm:text-md break-all"
|
||||
class:line-through={team.memberOne.dead}
|
||||
class:text-gray-500={team.memberOne.id == null}>{team.memberOne.username}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if team.memberTwo}
|
||||
<div class="flex items-center gap-x-2">
|
||||
<img
|
||||
class="w-4 h-4 pixelated"
|
||||
src={team.memberTwo.dead ? Skeleton.src : `https://mc-heads.net/head/${team.memberTwo.username}/8`}
|
||||
alt="head"
|
||||
/>
|
||||
<span class="text-xs sm:text-md" class:line-through={team.memberTwo.dead}
|
||||
>{team.memberTwo.username}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-x-2 w-max tooltip">
|
||||
{#if team.memberTwo.kills.length > 0 || team.memberTwo.dead}
|
||||
<div class="tooltip-content text-left space-y-1">
|
||||
{#each team.memberTwo.kills as kill (kill.killed.id)}
|
||||
<p>🔪 {kill.killed.username}</p>
|
||||
{/each}
|
||||
{#if team.memberTwo.dead}
|
||||
<p class="mt-2 first:mt-0">{team.memberTwo.dead.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if team.memberTwo.id != null}
|
||||
<div class="relative">
|
||||
<img class="h-4 pixelated" src="https://mc-heads.net/head/{team.memberTwo.username}/8" alt="head" />
|
||||
{#if aliveTeams === 1 && teamSignedUp && !teamDead}
|
||||
<div class="absolute -top-1.25 -right-1.25">
|
||||
<img class="h-3 w-3 rotate-30" src={crown.src} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-xs sm:text-md break-all"
|
||||
class:line-through={team.memberTwo.dead}
|
||||
class:text-gray-500={team.memberTwo.id == null}>{team.memberTwo.username}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs sm:text-md">0</span>
|
||||
<span class="text-xs sm:text-md">
|
||||
{team.memberOne.kills.length + team.memberTwo.kills.length}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
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
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 |
@ -111,11 +111,11 @@
|
||||
submitEnabled = false;
|
||||
for (const key of keys) {
|
||||
for (const k of key) {
|
||||
if (k.options?.required) {
|
||||
if (k.options?.validate) {
|
||||
if (k.options?.validate) {
|
||||
if (k.options?.required && !target[k.key]) {
|
||||
return;
|
||||
} else if (k.options?.required || target[k.key]) {
|
||||
if (!k.options.validate(target[k.key])) return;
|
||||
} else {
|
||||
if (!target[k.key]) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,9 +8,9 @@
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
|
||||
requestSuggestions: (query: string, limit: number) => Promise<{ name: string; value: string }[]>;
|
||||
|
||||
onSubmit?: (value: string | null) => void;
|
||||
onSubmit?: (value: { name: string; value: string } | null) => void;
|
||||
}
|
||||
|
||||
// html bindings
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
// states
|
||||
let inputValue = $derived(value);
|
||||
let suggestions = $state<string[]>([]);
|
||||
let suggestions = $state<{ name: string; value: string }[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
// callbacks
|
||||
@ -34,9 +34,9 @@
|
||||
|
||||
suggestions = await requestSuggestions(inputValue ?? '', 5);
|
||||
|
||||
let suggestion = suggestions.find((s) => s === inputValue);
|
||||
let suggestion = suggestions.find((s) => s.name === inputValue);
|
||||
if (suggestion != null) {
|
||||
inputValue = value = suggestion;
|
||||
inputValue = value = suggestion.name;
|
||||
matched = true;
|
||||
onSubmit?.(suggestion);
|
||||
} else if (!mustMatch) {
|
||||
@ -49,8 +49,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSuggestionClick(suggestion: string) {
|
||||
inputValue = value = suggestion;
|
||||
function onSuggestionClick(name: string) {
|
||||
const suggestion = suggestions.find((s) => s.name === name)!;
|
||||
|
||||
inputValue = value = suggestion.name;
|
||||
suggestions = [];
|
||||
onSubmit?.(suggestion);
|
||||
}
|
||||
@ -77,7 +79,7 @@
|
||||
bind:value={inputValue}
|
||||
oninput={() => onSearchInput()}
|
||||
onfocusin={() => onSearchInput()}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.map((s) => s.name).join('|')})$` : undefined}
|
||||
/>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||
@ -85,8 +87,8 @@
|
||||
<li class="w-full text-left">
|
||||
<button
|
||||
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={suggestion}
|
||||
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
|
||||
title={suggestion.name}
|
||||
onclick={() => onSuggestionClick(suggestion.name)}>{suggestion.name}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
@ -9,20 +9,17 @@
|
||||
// inputs
|
||||
let { available, value = $bindable(), readonly }: Props = $props();
|
||||
|
||||
// idk why, but this is needed to trigger loop reactivity
|
||||
let reactiveValue = $derived(value);
|
||||
|
||||
// callbacks
|
||||
function onOptionSelect(e: Event) {
|
||||
const selected = Number((e.target as HTMLSelectElement).value);
|
||||
|
||||
reactiveValue |= selected;
|
||||
value |= selected;
|
||||
|
||||
(e.target as HTMLSelectElement).value = '-';
|
||||
}
|
||||
|
||||
function onBadgeRemove(flag: number) {
|
||||
reactiveValue &= ~flag;
|
||||
value &= ~flag;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -31,20 +28,22 @@
|
||||
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||
<option selected hidden>-</option>
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
<option value={flag} hidden={(reactiveValue & Number(flag)) !== 0}>{badge}</option>
|
||||
<option value={flag} hidden={(value & Number(flag)) !== 0}>{badge}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex flow flex-wrap gap-2">
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
{#if (reactiveValue & Number(flag)) !== 0}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if !readonly}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||
{/if}
|
||||
<span>{badge}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#key value}
|
||||
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||
{#if (value & Number(flag)) !== 0}
|
||||
<div class="badge badge-outline gap-1">
|
||||
{#if !readonly}
|
||||
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||
{/if}
|
||||
<span>{badge}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -61,13 +61,47 @@ CREATE TABLE IF NOT EXISTS team_draft (
|
||||
|
||||
-- death
|
||||
CREATE TABLE IF NOT EXISTS death (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message VARCHAR(1024) NOT NULL,
|
||||
dead_user_id INT NOT NULL,
|
||||
dead_user_id INT NOT NULL UNIQUE,
|
||||
killer_user_id INT,
|
||||
FOREIGN KEY (dead_user_id) REFERENCES user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (killer_user_id) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
reason VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
url_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP,
|
||||
reporter_team_id INT,
|
||||
reported_team_id INT,
|
||||
FOREIGN KEY (reporter_team_id) REFERENCES team(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reported_team_id) REFERENCES team(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report attachment
|
||||
CREATE TABLE IF NOT EXISTS report_attachment (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('image', 'video') NOT NULL,
|
||||
hash CHAR(32) NOT NULL,
|
||||
report_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report status
|
||||
CREATE TABLE IF NOT EXISTS report_status (
|
||||
status ENUM('open', 'closed'),
|
||||
notice TEXT,
|
||||
statement TEXT,
|
||||
report_id INT NOT NULL UNIQUE,
|
||||
reviewer_id INT,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reviewer_id) REFERENCES admin(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- strike reason
|
||||
CREATE TABLE IF NOT EXISTS strike_reason (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@ -77,36 +111,11 @@ CREATE TABLE IF NOT EXISTS strike_reason (
|
||||
|
||||
-- strike
|
||||
CREATE TABLE IF NOT EXISTS strike (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
at TIMESTAMP NOT NULL,
|
||||
strike_reason_id INT NOT NULL,
|
||||
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
reason VARCHAR(255) NOT NULL,
|
||||
body TEXT,
|
||||
url_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP,
|
||||
reporter_team_id INT NOT NULL,
|
||||
reported_team_id INT,
|
||||
FOREIGN KEY (reporter_team_id) REFERENCES team(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reported_team_id) REFERENCES team(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report status
|
||||
CREATE TABLE IF NOT EXISTS report_status (
|
||||
status ENUM('open', 'closed'),
|
||||
notice TEXT,
|
||||
statement TEXT,
|
||||
report_id INT NOT NULL UNIQUE,
|
||||
reviewer_id INT,
|
||||
strike_id INT,
|
||||
strike_reason_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reviewer_id) REFERENCES admin(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (strike_id) REFERENCES strike(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (strike_reason_id) REFERENCES strike_reason(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- feedback
|
||||
@ -123,6 +132,6 @@ CREATE TABLE IF NOT EXISTS feedback (
|
||||
|
||||
-- settings
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
@ -30,7 +30,9 @@ import {
|
||||
type EditTeamReq,
|
||||
editTeam,
|
||||
type GetTeamsFullReq,
|
||||
getTeamsFull
|
||||
getTeamsFull,
|
||||
type GetTeamsByUsernameReq,
|
||||
getTeamsByUsername
|
||||
} from './schema/team';
|
||||
import {
|
||||
addTeamDraft,
|
||||
@ -48,6 +50,8 @@ import {
|
||||
type AddTeamMemberReq,
|
||||
deleteTeamMemberByTeamId,
|
||||
type DeleteTeamMemberByTeamIdReq,
|
||||
getTeamMembersByTeamId,
|
||||
type GetTeamMembersByTeamIdReq,
|
||||
teamMember
|
||||
} from './schema/teamMember';
|
||||
import {
|
||||
@ -79,9 +83,13 @@ import {
|
||||
setSettings
|
||||
} from './schema/settings';
|
||||
import {
|
||||
addDeath,
|
||||
type AddDeathReq,
|
||||
addDeath,
|
||||
death,
|
||||
deleteDeath,
|
||||
type DeleteDeathReq,
|
||||
editDeath,
|
||||
type EditDeathReq,
|
||||
getDeathByUserId,
|
||||
type GetDeathByUserIdReq,
|
||||
getDeaths,
|
||||
@ -93,10 +101,28 @@ import {
|
||||
addUserFeedbacks,
|
||||
type AddUserFeedbacksReq,
|
||||
feedback,
|
||||
getFeedbackByUrlHash,
|
||||
type GetFeedbackByUrlHash,
|
||||
getFeedbacks,
|
||||
type GetFeedbacksReq
|
||||
type GetFeedbacksReq,
|
||||
submitFeedback,
|
||||
type SubmitFeedbackReq
|
||||
} from './schema/feedback.ts';
|
||||
import { addReport, type AddReportReq, getReports, type GetReportsReq, report } from './schema/report.ts';
|
||||
import {
|
||||
addReport,
|
||||
type AddReportReq,
|
||||
editReport,
|
||||
type EditReportReq,
|
||||
getReportById,
|
||||
type GetReportById,
|
||||
getReportByUrlHash,
|
||||
type GetReportByUrlHash,
|
||||
getReports,
|
||||
type GetReportsReq,
|
||||
report,
|
||||
submitReport,
|
||||
type SubmitReportReq
|
||||
} from './schema/report.ts';
|
||||
import { DATABASE_URI } from 'astro:env/server';
|
||||
import {
|
||||
type GetStrikeReasonsReq,
|
||||
@ -109,7 +135,17 @@ import {
|
||||
type DeleteStrikeReasonReq,
|
||||
deleteStrikeReason
|
||||
} from '@db/schema/strikeReason.ts';
|
||||
import { getStrikesByTeamId, type GetStrikesByTeamId, strike } from '@db/schema/strike.ts';
|
||||
import {
|
||||
deleteStrike,
|
||||
type DeleteStrikeReq,
|
||||
editStrike,
|
||||
type EditStrikeReq,
|
||||
getStrikeByReportId,
|
||||
type GetStrikeByReportIdReq,
|
||||
getStrikesByTeamId,
|
||||
type GetStrikesByTeamIdReq,
|
||||
strike
|
||||
} from '@db/schema/strike.ts';
|
||||
import {
|
||||
editReportStatus,
|
||||
type EditReportStatusReq,
|
||||
@ -130,6 +166,13 @@ import {
|
||||
type DeleteBlockedUserReq,
|
||||
deleteBlockedUser
|
||||
} from '@db/schema/blockedUser.ts';
|
||||
import {
|
||||
addReportAttachment,
|
||||
type AddReportAttachmentReq,
|
||||
getReportAttachments,
|
||||
type GetReportAttachmentsReq,
|
||||
reportAttachment
|
||||
} from '@db/schema/reportAttachment.ts';
|
||||
|
||||
export class Database {
|
||||
protected readonly db: MySql2Database<{
|
||||
@ -141,6 +184,7 @@ export class Database {
|
||||
blockedUser: typeof blockedUser;
|
||||
death: typeof death;
|
||||
report: typeof report;
|
||||
reportAttachment: typeof reportAttachment;
|
||||
reportStatus: typeof reportStatus;
|
||||
strike: typeof strike;
|
||||
strikeReason: typeof strikeReason;
|
||||
@ -168,6 +212,7 @@ export class Database {
|
||||
blockedUser,
|
||||
death,
|
||||
report,
|
||||
reportAttachment,
|
||||
reportStatus,
|
||||
strike,
|
||||
strikeReason,
|
||||
@ -217,6 +262,7 @@ export class Database {
|
||||
getTeamById = (values: GetTeamByIdReq) => getTeamById(this.db, values);
|
||||
getTeamByName = (values: GetTeamByNameReq) => getTeamByName(this.db, values);
|
||||
getTeamByUserUuid = (values: GetTeamByUserUuidReq) => getTeamByUserUuid(this.db, values);
|
||||
getTeamsByUsername = (values: GetTeamsByUsernameReq) => getTeamsByUsername(this.db, values);
|
||||
|
||||
/* team draft */
|
||||
addTeamDraft = (values: AddTeamDraftReq) => addTeamDraft(this.db, values);
|
||||
@ -227,15 +273,26 @@ export class Database {
|
||||
/* team member */
|
||||
addTeamMember = (values: AddTeamMemberReq) => addTeamMember(this.db, values);
|
||||
deleteTeamMemberByTeamId = (values: DeleteTeamMemberByTeamIdReq) => deleteTeamMemberByTeamId(this.db, values);
|
||||
getTeamMembersByTeamId = (values: GetTeamMembersByTeamIdReq) => getTeamMembersByTeamId(this.db, values);
|
||||
|
||||
/* death */
|
||||
addDeath = (values: AddDeathReq) => addDeath(this.db, values);
|
||||
editDeath = (values: EditDeathReq) => editDeath(this.db, values);
|
||||
deleteDeath = (values: DeleteDeathReq) => deleteDeath(this.db, values);
|
||||
getDeathByUserId = (values: GetDeathByUserIdReq) => getDeathByUserId(this.db, values);
|
||||
getDeaths = (values: GetDeathsReq) => getDeaths(this.db, values);
|
||||
|
||||
/* report */
|
||||
addReport = (values: AddReportReq) => addReport(this.db, values);
|
||||
editReport = (values: EditReportReq) => editReport(this.db, values);
|
||||
submitReport = (values: SubmitReportReq) => submitReport(this.db, values);
|
||||
getReports = (values: GetReportsReq) => getReports(this.db, values);
|
||||
getReportById = (values: GetReportById) => getReportById(this.db, values);
|
||||
getReportByUrlHash = (values: GetReportByUrlHash) => getReportByUrlHash(this.db, values);
|
||||
|
||||
/* report attachment */
|
||||
addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values);
|
||||
getReportAttachments = (values: GetReportAttachmentsReq) => getReportAttachments(this.db, values);
|
||||
|
||||
/* report status */
|
||||
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);
|
||||
@ -248,12 +305,17 @@ export class Database {
|
||||
getStrikeReasons = (values: GetStrikeReasonsReq) => getStrikeReasons(this.db, values);
|
||||
|
||||
/* strikes */
|
||||
getStrikesByTeamId = (values: GetStrikesByTeamId) => getStrikesByTeamId(this.db, values);
|
||||
editStrike = (values: EditStrikeReq) => editStrike(this.db, values);
|
||||
deleteStrike = (values: DeleteStrikeReq) => deleteStrike(this.db, values);
|
||||
getStrikeByReportId = (values: GetStrikeByReportIdReq) => getStrikeByReportId(this.db, values);
|
||||
getStrikesByTeamId = (values: GetStrikesByTeamIdReq) => getStrikesByTeamId(this.db, values);
|
||||
|
||||
/* feedback */
|
||||
addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values);
|
||||
addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values);
|
||||
submitFeedback = (values: SubmitFeedbackReq) => submitFeedback(this.db, values);
|
||||
getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values);
|
||||
getFeedbackByUrlHash = (values: GetFeedbackByUrlHash) => getFeedbackByUrlHash(this.db, values);
|
||||
|
||||
/* settings */
|
||||
getSettings = (values: GetSettingsReq) => getSettings(this.db, values);
|
||||
|
@ -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,
|
||||
@ -73,3 +90,9 @@ export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
|
||||
.from(feedback)
|
||||
.leftJoin(user, eq(feedback.userId, user.id));
|
||||
}
|
||||
|
||||
export async function getFeedbackByUrlHash(db: Database, values: GetFeedbackByUrlHash) {
|
||||
return db.query.feedback.findFirst({
|
||||
where: eq(feedback.urlHash, values.urlHash)
|
||||
});
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { char, int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { aliasedTable, and, asc, desc, eq, like, sql } from 'drizzle-orm';
|
||||
import { aliasedTable, and, asc, desc, eq, like, or, sql } from 'drizzle-orm';
|
||||
import { teamMember } from './teamMember.ts';
|
||||
import { user } from './user.ts';
|
||||
import { teamDraft } from './teamDraft.ts';
|
||||
import { death } from '@db/schema/death.ts';
|
||||
import { report } from '@db/schema/report.ts';
|
||||
import { reportStatus } from '@db/schema/reportStatus.ts';
|
||||
import { strikeReason } from '@db/schema/strikeReason.ts';
|
||||
import { strike } from '@db/schema/strike.ts';
|
||||
|
||||
@ -50,6 +49,11 @@ export type GetTeamByUserUuidReq = {
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export type GetTeamsByUsernameReq = {
|
||||
username: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export async function addTeam(db: Database, values: AddTeamReq) {
|
||||
let color = values.color;
|
||||
if (!color) {
|
||||
@ -123,8 +127,10 @@ export async function getTeams(db: Database, values: GetTeamsReq) {
|
||||
.where(
|
||||
and(
|
||||
values?.name != null ? like(team.name, `%${values.name}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
||||
or(
|
||||
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(team.id))
|
||||
@ -142,8 +148,7 @@ export async function getTeamsFull(db: Database, _values: GetTeamsFullReq) {
|
||||
})
|
||||
.from(strike)
|
||||
.innerJoin(strikeReason, eq(strike.strikeReasonId, strikeReason.id))
|
||||
.innerJoin(reportStatus, eq(strike.id, reportStatus.strikeId))
|
||||
.innerJoin(report, eq(reportStatus.reportId, report.id))
|
||||
.innerJoin(report, eq(strike.reportId, report.id))
|
||||
.innerJoin(team, eq(report.reportedTeamId, team.id))
|
||||
.as('strike_weight_subquery');
|
||||
|
||||
@ -184,6 +189,18 @@ export async function getTeamByUserUuid(db: Database, values: GetTeamByUserUuidR
|
||||
return teams[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getTeamsByUsername(db: Database, values: GetTeamsByUsernameReq) {
|
||||
return db
|
||||
.select({
|
||||
user: user,
|
||||
team: team
|
||||
})
|
||||
.from(user)
|
||||
.where(like(user.username, `%${values.username}%`))
|
||||
.innerJoin(teamMember, eq(user.id, teamMember.userId))
|
||||
.innerJoin(team, eq(teamMember.teamId, team.id));
|
||||
}
|
||||
|
||||
const teamColors = [
|
||||
'#cd853f',
|
||||
'#ff7f50',
|
||||
|
@ -24,6 +24,10 @@ export type DeleteTeamMemberByTeamIdReq = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type GetTeamMembersByTeamIdReq = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
|
||||
const teamMemberIds = await db.insert(teamMember).values(values).$returningId();
|
||||
|
||||
@ -33,3 +37,13 @@ export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
|
||||
export async function deleteTeamMemberByTeamId(db: Database, values: DeleteTeamMemberByTeamIdReq) {
|
||||
await db.delete(teamMember).where(eq(teamMember.teamId, values.teamId));
|
||||
}
|
||||
|
||||
export async function getTeamMembersByTeamId(db: Database, values: GetTeamMembersByTeamIdReq) {
|
||||
return db
|
||||
.select({
|
||||
user: user
|
||||
})
|
||||
.from(teamMember)
|
||||
.innerJoin(user, eq(teamMember.userId, user.id))
|
||||
.where(eq(teamMember.teamId, values.teamId));
|
||||
}
|
||||
|
@ -40,6 +40,13 @@ const adminTabs = [
|
||||
href: 'admin/teams',
|
||||
name: 'Teams',
|
||||
icon: 'heroicons:users',
|
||||
subTabs: [
|
||||
{
|
||||
href: 'admin/teams/dead',
|
||||
name: 'Tote Spieler',
|
||||
icon: 'heroicons:x-mark'
|
||||
}
|
||||
],
|
||||
enabled: session?.permissions.users
|
||||
},
|
||||
{
|
||||
@ -72,6 +79,12 @@ const adminTabs = [
|
||||
name: 'Einstellungen',
|
||||
icon: 'heroicons:adjustments-horizontal',
|
||||
enabled: session?.permissions.settings
|
||||
},
|
||||
{
|
||||
href: 'admin/tools',
|
||||
name: 'Tools',
|
||||
icon: 'heroicons:wrench-screwdriver',
|
||||
enabled: session?.permissions.tools
|
||||
}
|
||||
];
|
||||
---
|
||||
@ -92,26 +105,29 @@ const adminTabs = [
|
||||
}
|
||||
<div class="divider mx-1 my-1"></div>
|
||||
{
|
||||
adminTabs.map((tab) => (
|
||||
<li>
|
||||
<a href={tab.href}>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
{tab.subTabs && (
|
||||
<ul>
|
||||
{tab.subTabs.map((subTab) => (
|
||||
<li>
|
||||
<a href={subTab.href}>
|
||||
<Icon name={subTab.icon} />
|
||||
<span>{subTab.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
adminTabs.map(
|
||||
(tab) =>
|
||||
tab.enabled && (
|
||||
<li>
|
||||
<a href={tab.href}>
|
||||
<Icon name={tab.icon} />
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
{tab.subTabs && (
|
||||
<ul>
|
||||
{tab.subTabs.map((subTab) => (
|
||||
<li>
|
||||
<a href={subTab.href}>
|
||||
<Icon name={subTab.icon} />
|
||||
<span>{subTab.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
Astro.slots.has('actions') && (
|
||||
|
27
src/pages/admin/reports/attachment/[fileHash].ts
Normal file
27
src/pages/admin/reports/attachment/[fileHash].ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { UPLOAD_PATH } from 'astro:env/server';
|
||||
|
||||
export const GET: APIRoute = async ({ params, cookies }) => {
|
||||
Session.actionSessionFromCookies(cookies, Permissions.Reports);
|
||||
|
||||
if (!UPLOAD_PATH) return new Response(null, { status: 404 });
|
||||
|
||||
const fileHash = params.fileHash as string;
|
||||
const filePath = path.join(UPLOAD_PATH, fileHash);
|
||||
|
||||
if (!fs.existsSync(filePath)) return new Response(null, { status: 404 });
|
||||
|
||||
const fileStat = fs.statSync(filePath);
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
return new Response(fileStream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': fileStat.size.toString()
|
||||
}
|
||||
});
|
||||
};
|
16
src/pages/admin/teams/dead.astro
Normal file
16
src/pages/admin/teams/dead.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import AdminLayout from '@layouts/admin/AdminLayout.astro';
|
||||
import SidebarActions from '@app/admin/teamDeaths/SidebarActions.svelte';
|
||||
import TeamDeaths from '@app/admin/teamDeaths/TeamDeaths.svelte';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
<AdminLayout title="Tote Spieler">
|
||||
<SidebarActions slot="actions" client:load />
|
||||
<TeamDeaths client:load />
|
||||
</AdminLayout>
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
14
src/pages/admin/tools/index.astro
Normal file
14
src/pages/admin/tools/index.astro
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
import { Session } from '@util/session';
|
||||
import { Permissions } from '@util/permissions';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
import AdminLayout from '@layouts/admin/AdminLayout.astro';
|
||||
import Tools from '@app/admin/tools/Tools.svelte';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Tools);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
<AdminLayout title="Reports">
|
||||
<Tools client:load />
|
||||
</AdminLayout>
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { BASE_PATH } from 'astro:env/server';
|
||||
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
|
||||
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Users);
|
||||
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
|
||||
---
|
||||
|
||||
|
@ -42,7 +42,7 @@ const team = [
|
||||
<img
|
||||
class="m-[7.5px]"
|
||||
style="width: 85%; height: 85%"
|
||||
src={`https://mc-heads.net/head/${member.nickname}`}
|
||||
src={`https://mc-heads.net/head/${member.nickname.toLowerCase()}`}
|
||||
alt={member.name}
|
||||
/>
|
||||
</div>
|
||||
|
107
src/pages/api/report/index.ts
Normal file
107
src/pages/api/report/index.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { z } from 'astro:schema';
|
||||
import { API_SECRET } from 'astro:env/server';
|
||||
import { db } from '@db/database.ts';
|
||||
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
|
||||
|
||||
const postSchema = z.object({
|
||||
reporter: z.string(),
|
||||
reported: z.string().nullable(),
|
||||
reason: z.string()
|
||||
});
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (API_SECRET && request.headers.get('authorization') !== `Basic ${API_SECRET}`) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await postSchema.parseAsync(await request.json());
|
||||
} catch (_) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const reporterTeam = await db.getTeamByUserUuid({ uuid: parsed.reporter });
|
||||
if (!reporterTeam) return new Response(null, { status: 404 });
|
||||
|
||||
let reportedTeam = null;
|
||||
if (parsed.reported) {
|
||||
reportedTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
|
||||
if (!reportedTeam) return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const report = await db.addReport({
|
||||
reporterTeamId: reporterTeam.team.id,
|
||||
reportedTeamId: reportedTeam?.team.id,
|
||||
reason: parsed.reason,
|
||||
body: null
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: report.url }), { status: 200 });
|
||||
};
|
||||
|
||||
const putSchema = z.object({
|
||||
reporter: z.string().nullable(),
|
||||
reported: z.string(),
|
||||
reason: z.string(),
|
||||
body: z.string().nullable(),
|
||||
notice: z.string().nullable(),
|
||||
statement: z.string().nullable(),
|
||||
strike_reason_id: z.number()
|
||||
});
|
||||
|
||||
export const PUT: APIRoute = async ({ request }) => {
|
||||
if (API_SECRET && request.headers.get('authorization') !== `Basic ${API_SECRET}`) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await putSchema.parseAsync(await request.json());
|
||||
} catch (_) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
let reporterTeam = null;
|
||||
if (parsed.reported) {
|
||||
reporterTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
|
||||
if (!reporterTeam) return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const reportedTeam = await db.getTeamByUserUuid({ uuid: parsed.reported });
|
||||
if (!reportedTeam) return new Response(null, { status: 404 });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const report = await tx.addReport({
|
||||
reporterTeamId: reporterTeam?.team.id,
|
||||
reportedTeamId: reportedTeam.team.id,
|
||||
createdAt: new Date(),
|
||||
reason: parsed.reason,
|
||||
body: parsed.body
|
||||
});
|
||||
|
||||
await tx.editReportStatus({
|
||||
reportId: report.id,
|
||||
notice: parsed.notice,
|
||||
statement: parsed.statement,
|
||||
status: 'closed'
|
||||
});
|
||||
|
||||
await tx.editStrike({
|
||||
reportId: report.id,
|
||||
strikeReasonId: parsed.strike_reason_id
|
||||
});
|
||||
});
|
||||
|
||||
const strikes = await db.getStrikesByTeamId({ teamId: reportedTeam.team.id });
|
||||
const teamMembers = await db.getTeamMembersByTeamId({ teamId: reportedTeam.team.id });
|
||||
|
||||
// send webhook in background
|
||||
sendWebhook(WebhookAction.Strike, {
|
||||
users: teamMembers.map((tm) => tm.user.uuid!),
|
||||
totalWeight: strikes.map((strike) => strike.reason.weight).reduce((a, b) => a + b, 0)
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
@ -164,6 +164,10 @@ verkleinert. Du wirst im Spiel über alles wichtige informiert, sobald du dich d
|
||||
title: 'Kann ich ein eigenes Netherportal bauen?',
|
||||
content: `<p>Nein, das einzige Netherportal steht am Spawn. Weitere können nicht eröffnet werden.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Darf ich Fallen bauen?',
|
||||
content: `<p>Ja, du darfst überall Fallen bauen, außer unmittellbar um das Nether-Portal am Spawn.</p>`
|
||||
},
|
||||
{
|
||||
title: 'Wie kann ich einen Report erstellen?',
|
||||
content: `<p>Neben der Kontrolle durch die Admins kann mit /report ein Report erstellt werden, um Regelverstöße
|
||||
|
66
src/pages/feedback/[urlHash].astro
Normal file
66
src/pages/feedback/[urlHash].astro
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import { db } from '@db/database.ts';
|
||||
|
||||
const { urlHash } = Astro.params;
|
||||
|
||||
const feedback = urlHash ? await db.getFeedbackByUrlHash({ urlHash: urlHash }) : null;
|
||||
|
||||
if (!feedback) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
---
|
||||
|
||||
<WebsiteLayout title="Feedback">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
|
||||
<h2 class="text-3xl text-center">Feedback</h2>
|
||||
<form id="feedback" data-url-hash={urlHash}>
|
||||
<div class="space-y-4 mt-6 mb-4">
|
||||
<Input value={feedback.title} label="Event" dynamicWidth readonly />
|
||||
<Textarea
|
||||
id="content"
|
||||
value={feedback.content}
|
||||
label="Feedback"
|
||||
rows={10}
|
||||
dynamicWidth
|
||||
required
|
||||
readonly={feedback.content !== null}
|
||||
/>
|
||||
</div>
|
||||
<button id="send" class="btn" disabled>Feedback senden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</WebsiteLayout>
|
||||
|
||||
<script>
|
||||
import { actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action';
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const form = document.getElementById('feedback') as HTMLFormElement;
|
||||
const content = document.getElementById('content') as HTMLTextAreaElement;
|
||||
const sendButton = document.getElementById('send') as HTMLButtonElement;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { error } = await actions.feedback.submitFeedback({
|
||||
urlHash: form.dataset.urlHash!,
|
||||
content: content.value
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
content.readOnly = true;
|
||||
sendButton.disabled = true;
|
||||
});
|
||||
content.addEventListener('input', () => (sendButton.disabled = content.value === '' || content.readOnly));
|
||||
});
|
||||
</script>
|
@ -13,6 +13,7 @@ const teams = await db.getTeams({});
|
||||
const deaths = await db.getDeaths({});
|
||||
|
||||
const signupEnabled = await getSetting(db, SettingKey.SignupEnabled, false);
|
||||
const signupInfoMessage = await getSetting(db, SettingKey.SignupInfoMessage);
|
||||
|
||||
const information = [
|
||||
{
|
||||
@ -64,6 +65,7 @@ const information = [
|
||||
>{signupEnabled ? 'Jetzt registrieren' : 'Infos zur Anmeldung'}</a
|
||||
>
|
||||
</div>
|
||||
{signupInfoMessage && <span class="text-center text-xs text-base-content/80 mt-3">{signupInfoMessage}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -101,10 +103,14 @@ const information = [
|
||||
|
||||
<div class="bg-base-100 flex flex-col space-y-10 items-center py-10">
|
||||
<h2 id="teams" class="text-4xl mb-10">Teams</h2>
|
||||
<p class="text-sm text-center mb-2 mx-1">
|
||||
Bei unvollständigen Teams muss sich der zweite Mitspieler noch registrieren. Unvollständige Teams werden bei
|
||||
Anmeldeschluss gelöscht.
|
||||
</p>
|
||||
{
|
||||
signupEnabled && (
|
||||
<p class="text-sm text-center mb-2 mx-1">
|
||||
Bei unvollständigen Teams muss sich der zweite Mitspieler noch registrieren. Unvollständige Teams werden bei
|
||||
Anmeldeschluss gelöscht.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<Teams {teams} {deaths} />
|
||||
</div>
|
||||
</WebsiteLayout>
|
||||
|
23
src/pages/report/[urlHash].astro
Normal file
23
src/pages/report/[urlHash].astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
|
||||
import { db } from '@db/database.ts';
|
||||
import Draft from './_draft.astro';
|
||||
import Submitted from './_submitted.astro';
|
||||
import Popup from '@components/popup/Popup.svelte';
|
||||
import ConfirmPopup from '@components/popup/ConfirmPopup.svelte';
|
||||
|
||||
const { urlHash } = Astro.params;
|
||||
|
||||
const report = urlHash ? await db.getReportByUrlHash({ urlHash: urlHash }) : null;
|
||||
|
||||
if (!report) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
---
|
||||
|
||||
<WebsiteLayout title="Report">
|
||||
{report.createdAt === null ? <Draft report={report} /> : <Submitted report={report} />}
|
||||
</WebsiteLayout>
|
||||
|
||||
<Popup client:idle />
|
||||
<ConfirmPopup client:idle />
|
105
src/pages/report/_draft.astro
Normal file
105
src/pages/report/_draft.astro
Normal file
@ -0,0 +1,105 @@
|
||||
---
|
||||
import type { db } from '@db/database.ts';
|
||||
import AdversarySearch from '@app/website/report/AdversarySearch.svelte';
|
||||
import Dropzone from '@app/website/report/Dropzone.svelte';
|
||||
import Input from '@components/input/Input.svelte';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
import { MAX_UPLOAD_BYTES } from 'astro:env/server';
|
||||
|
||||
interface Props {
|
||||
report: Awaited<ReturnType<db.getReportByUrlHash>>;
|
||||
}
|
||||
|
||||
const { report } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
|
||||
<h2 class="text-3xl text-center">
|
||||
Report von Team <span class="underline">A</span> gegen Team <span id="adversary-team-name" class="underline"
|
||||
>B</span
|
||||
>
|
||||
</h2>
|
||||
<form id="report" data-url-hash={report.urlHash} data-adversary-team={report.reported?.name}>
|
||||
<div class="space-y-4 my-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<AdversarySearch
|
||||
adversary={{ type: report.reported ? 'team' : 'unknown', name: report.reported?.name }}
|
||||
client:load
|
||||
/>
|
||||
<Input id="reason" value={report.reason} label="Report Grund" dynamicWidth />
|
||||
<Textarea id="body" value={report.body} label="Details" rows={10} dynamicWidth required />
|
||||
<Dropzone maxFilesBytes={MAX_UPLOAD_BYTES} client:load />
|
||||
</div>
|
||||
<button id="send" class="btn" disabled={report.body}>Report senden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { actions } from 'astro:actions';
|
||||
import { actionErrorPopup } from '@util/action';
|
||||
import { popupState } from '@components/popup/Popup';
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const eventCancelController = new AbortController();
|
||||
document.addEventListener('astro:after-swap', () => eventCancelController.abort());
|
||||
|
||||
const adversary = document.getElementById('adversary-team-name') as HTMLSpanElement;
|
||||
const form = document.getElementById('report') as HTMLFormElement;
|
||||
const reason = document.getElementById('reason') as HTMLInputElement;
|
||||
const body = document.getElementById('body') as HTMLTextAreaElement;
|
||||
const sendButton = document.getElementById('send') as HTMLButtonElement;
|
||||
|
||||
let attachments: File[] = [];
|
||||
|
||||
body.addEventListener('change', () => {
|
||||
sendButton.disabled = !body.value;
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
'adversaryInput',
|
||||
(e: any & { detail: { adversaryTeamName: string } }) => {
|
||||
adversary.textContent = e.detail.adversaryTeamName;
|
||||
},
|
||||
{ signal: eventCancelController.signal }
|
||||
);
|
||||
document.addEventListener(
|
||||
'dropzoneInput',
|
||||
(e: any & { detail: { files: File[] } }) => {
|
||||
attachments = e.detail.files;
|
||||
},
|
||||
{ signal: eventCancelController.signal }
|
||||
);
|
||||
|
||||
form.addEventListener(
|
||||
'submit',
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('urlHash', form.dataset.urlHash!);
|
||||
formData.set('reason', reason.value);
|
||||
formData.set('body', body.value);
|
||||
for (const attachment of attachments) {
|
||||
formData.append('files', attachment);
|
||||
}
|
||||
|
||||
const { error } = await actions.report.submitReport(formData);
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return;
|
||||
}
|
||||
|
||||
popupState.set({
|
||||
type: 'info',
|
||||
title: 'Report abgeschickt',
|
||||
message: 'Der Report wurde abgeschickt. Ein Admin wird sich schnellstmöglich darum kümmern.',
|
||||
onClose: () => location.reload()
|
||||
});
|
||||
},
|
||||
{ signal: eventCancelController.signal }
|
||||
);
|
||||
});
|
||||
</script>
|
33
src/pages/report/_submitted.astro
Normal file
33
src/pages/report/_submitted.astro
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
import type { db } from '@db/database.ts';
|
||||
import Textarea from '@components/input/Textarea.svelte';
|
||||
|
||||
interface Props {
|
||||
report: Awaited<ReturnType<db.getReportByUrlHash>>;
|
||||
}
|
||||
|
||||
const { report } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="mt-12 grid card w-11/12 xl:w-2/3 2xl:w-1/2 p-6 shadow-lg">
|
||||
{
|
||||
report.status?.status == null ? (
|
||||
<p>Dein Report wird in kürze bearbeitet</p>
|
||||
) : report.status?.status === 'open' ? (
|
||||
<p>Dein Report befindet sich in Bearbeitung</p>
|
||||
) : (
|
||||
<>
|
||||
<p>Dein Report wurde bearbeitet</p>
|
||||
<Textarea
|
||||
value={report.status?.statement}
|
||||
label="Antwort vom Admin Team (optional)"
|
||||
rows={5}
|
||||
dynamicWidth
|
||||
readonly
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
@ -76,8 +76,8 @@ langfristigem Projektausschluss rechnen.</p>`
|
||||
<br>
|
||||
Es gibt außerdem eine <b>Worldborder</b>, die sich mit der Zeit - außerhalb der Spielzeiten - langsam verkleinert. Die
|
||||
Spieler werden im Spiel über alles wichtige informiert, sobald sie sich der Border gefährlich nähern.<br>
|
||||
Das einzige <b>Netherportal</b> steht am Spawn. Weitere können nicht eröffnet werden. Für das Beste Spielerlebnis sind
|
||||
folgende <b>Spielinhalte deaktiviert</b>:
|
||||
Das einzige <b>Netherportal</b> steht am Spawn. Unmittelbar um das Nether-Portal dürfen keine Fallen errichtet werden.
|
||||
Weitere können nicht eröffnet werden. Für das Beste Spielerlebnis sind folgende <b>Spielinhalte deaktiviert</b>:
|
||||
<ol class="list-disc pl-8 py-3">
|
||||
<li>alle Netherite Items</li>
|
||||
<li>alle Tränke der Stufe 2, ausgenommen Direktheilung 2</li>
|
||||
|
2
src/util/media.ts
Normal file
2
src/util/media.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
|
||||
export const allowedVideoTypes = ['video/mp4', 'video/webm'];
|
@ -1,10 +1,10 @@
|
||||
export async function getJavaUuid(username: string) {
|
||||
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
|
||||
if (!response.ok) {
|
||||
// rate limit
|
||||
if (response.status == 429) return null;
|
||||
// user doesn't exist
|
||||
else if (response.status < 500) throw new Error();
|
||||
if (response.status == 400 || response.status == 404) throw new Error();
|
||||
// rate limit
|
||||
else if (response.status == 429) return null;
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
@ -12,3 +12,46 @@ export async function getJavaUuid(username: string) {
|
||||
// prettier-ignore
|
||||
return `${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}`;
|
||||
}
|
||||
|
||||
// https://github.com/carlop3333/XUIDGrabber/blob/main/grabber.js
|
||||
export async function getBedrockUuid(username: string): Promise<string> {
|
||||
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
|
||||
const initialPageContent = await initialPageResponse.text();
|
||||
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
|
||||
|
||||
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
|
||||
if (token === undefined || cookies === undefined || cookies.length < 11) return null;
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.set('_token', token);
|
||||
requestBody.set('gamertag', username);
|
||||
|
||||
const resultPageResponse = await fetch('https://cxkes.me/xbox/xuid', {
|
||||
method: 'post',
|
||||
body: requestBody,
|
||||
// prettier-ignore
|
||||
headers: {
|
||||
'Host': 'www.cxkes.me',
|
||||
'Accept-Encoding': 'gzip, deflate,br',
|
||||
'Content-Length': Buffer.byteLength(requestBody.toString()).toString(),
|
||||
'Origin': 'https://www.cxkes.me',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://www.cxkes.me/xbox/xuid',
|
||||
'Cookie': `${cookies[0]} ${cookies[10].slice(0, cookies[10].length - 1)}`,
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fectch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
const resultPageContent = await resultPageResponse.text();
|
||||
let xuid: string | undefined;
|
||||
if ((xuid = /id="xuidHex">(?<xuid>\w+)</.exec(resultPageContent)?.groups?.xuid) === undefined) throw new Error();
|
||||
return `00000000-0000-0000-${xuid.substring(0, 4)}-${xuid.substring(4)}`;
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
export function getObjectEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
if ((entry = entry[part]) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
entry = entry[part];
|
||||
if (entry === null || typeof entry !== 'object') return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
@ -53,10 +53,10 @@ export class Permissions {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
}
|
||||
get feedback() {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
return (this.value & Permissions.Feedback.value) != 0;
|
||||
}
|
||||
get settings() {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
return (this.value & Permissions.Settings.value) != 0;
|
||||
}
|
||||
get tools() {
|
||||
return (this.value & Permissions.Tools.value) != 0;
|
||||
|
@ -39,12 +39,14 @@ export async function getSettings<K extends SettingKey[]>(db: Database, keys?: K
|
||||
|
||||
export enum SettingKey {
|
||||
SignupEnabled = 'signup.enabled',
|
||||
SignupInfoMessage = 'signup.infoMessage',
|
||||
SignupDisabledMessage = 'signup.disabledMessage',
|
||||
SignupDisabledSubMessage = 'signup.disabledSubMessage'
|
||||
}
|
||||
|
||||
export type SettingKeyValueType<K extends SettingKey> = {
|
||||
[SettingKey.SignupEnabled]: boolean;
|
||||
[SettingKey.SignupInfoMessage]: string;
|
||||
[SettingKey.SignupDisabledMessage]: string;
|
||||
[SettingKey.SignupDisabledSubMessage]: string;
|
||||
}[K];
|
||||
|
@ -18,11 +18,12 @@ export async function sendWebhook<T extends WebhookAction>(action: T, data: Webh
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(WEBHOOK_ENDPOINT, {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-webhook-action': action
|
||||
},
|
||||
keepalive: false
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.status === 200) return;
|
||||
|
Reference in New Issue
Block a user