add feedback and report things
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 21s

This commit is contained in:
2025-06-21 14:45:39 +02:00
parent 9c49585873
commit ee8f595ecc
25 changed files with 898 additions and 57 deletions

View File

@ -6,6 +6,8 @@ ADMIN_USER=admin
ADMIN_PASSWORD=admin ADMIN_PASSWORD=admin
ADMIN_COOKIE=muelleel ADMIN_COOKIE=muelleel
UPLOAD_PATH=/tmp
YOUTUBE_INTRO_LINK=https://www.youtube-nocookie.com/embed/e78_QbTNb4s YOUTUBE_INTRO_LINK=https://www.youtube-nocookie.com/embed/e78_QbTNb4s
TEAMSPEAK_LINK=http://example.com TEAMSPEAK_LINK=http://example.com
DISCORD_LINK=http://example.com DISCORD_LINK=http://example.com

View File

@ -38,6 +38,9 @@ export default defineConfig({
ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }), ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }), 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' }), START_DATE: envField.string({ context: 'server', access: 'secret', default: '1970-01-01' }),
WEBHOOK_ENDPOINT: envField.string({ context: 'server', access: 'secret', optional: true }), WEBHOOK_ENDPOINT: envField.string({ context: 'server', access: 'secret', optional: true }),

View File

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

View File

@ -1,10 +1,84 @@
import { defineAction } from 'astro:actions'; import { ActionError, defineAction } from 'astro:actions';
import { Session } from '@util/session.ts'; import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts'; import { Permissions } from '@util/permissions.ts';
import { db } from '@db/database.ts'; import { db } from '@db/database.ts';
import { z } from 'astro:schema'; import { z } from 'astro:schema';
import { 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';
export const report = { export const report = {
submitReport: defineAction({
input: z.object({
urlHash: z.string(),
reason: z.string(),
body: z.string(),
files: z.array(z.instanceof(File)).nullable()
}),
handler: async (input) => {
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);
await tx.addReportAttachment({
type: 'video',
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({ addReport: defineAction({
input: z.object({ input: z.object({
reason: z.string(), reason: z.string(),
@ -19,7 +93,7 @@ export const report = {
const { id } = await db.addReport({ const { id } = await db.addReport({
reason: input.reason, reason: input.reason,
body: input.body, body: input.body,
createdAt: input.createdAt, createdAt: input.createdAt ? new Date(input.createdAt) : null,
reporterTeamId: input.reporter, reporterTeamId: input.reporter,
reportedTeamId: input.reported reportedTeamId: input.reported
}); });
@ -52,6 +126,9 @@ export const report = {
handler: async (input, context) => { handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports); Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
let preReportStrike;
if (input.status === 'closed') preReportStrike = await db.getStrikeByReportId({ reportId: input.reportId });
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.editReportStatus(input); await tx.editReportStatus(input);
@ -62,6 +139,20 @@ export const report = {
}); });
} }
}); });
if (input.status === 'closed' && preReportStrike?.strikeReasonId != 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({ reports: defineAction({
@ -118,5 +209,29 @@ export const report = {
strikeReasons: await db.getStrikeReasons({}) strikeReasons: await db.getStrikeReasons({})
}; };
} }
}),
teamNamesByUsername: defineAction({
input: z.object({
username: z.string().nullish()
}),
handler: async (input) => {
const teams = await db.getTeamsByUsername({ username: input.username ?? '', limit: 5 });
return {
teamNames: teams.map((team) => ({ name: team.user.username, value: team.team.name }))
};
}
}),
teamNamesByTeamName: defineAction({
input: z.object({
teamName: z.string().nullish()
}),
handler: async (input) => {
const teams = await db.getTeams({ name: input.teamName, limit: 5 });
return {
teamNames: teams.map((team) => ({ name: team.name, value: team.name }))
};
}
}) })
}; };

View File

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

View File

@ -0,0 +1,183 @@
<script lang="ts">
import { popupState } from '@components/popup/Popup.ts';
// bindings
let containerElem: HTMLDivElement;
let hiddenFileInputElem: HTMLInputElement;
let previewDialogElem: HTMLDialogElement;
// consts
const allowedImageTypes = ['image/png', 'image/jpeg', 'image/avif'];
const allowedVideoTypes = ['video/mp4'];
// 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, .avif) und Videos (.mp4) 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 ${(maxFilesBytes / 1024 / 1024).toFixed(2)}MB groß sein`
};
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
bind:this={containerElem}
class="h-12 rounded border border-dashed flex cursor-pointer"
class:h-26={uploadFiles.length > 0}
dropzone="copy"
onclick={() => hiddenFileInputElem.click()}
ondrop={onDrop}
ondragover={(e) => e.preventDefault()}
>
{#if uploadFiles.length === 0}
<div class="flex justify-center items-center w-full h-full">
<p>Hier Dateien droppen oder klicken um sie hochzuladen</p>
</div>
{/if}
{#each uploadFiles as uploadFile (uploadFile.name)}
<div
class="relative flex flex-col items-center w-22 h-22 m-1 cursor-default"
onclick={(e) => e.stopImmediatePropagation()}
>
<div class="cursor-zoom-in" onclick={() => (previewUploadFile = uploadFile)}>
{#if uploadFile.type === 'image'}
<img src={uploadFile.dataUrl} alt={uploadFile.name} class="w-16 h-16" />
{:else if uploadFile.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={uploadFile.dataUrl} class="w-16 h-16"></video>
{/if}
</div>
<span>{bytesToHumanReadable(uploadFile.size)}</span>
<button class="cursor-pointer" onclick={() => onFileRemove(uploadFile)}>Datei entfernen</button>
</div>
{/each}
</div>
</fieldset>
<input
bind:this={hiddenFileInputElem}
type="file"
multiple
accept={[...allowedImageTypes, ...allowedVideoTypes].join(', ')}
class="hidden absolute top-0 left-0 h-0 w-0"
onchange={onAddFiles}
/>
<dialog class="modal" bind:this={previewDialogElem} onclose={() => setTimeout(() => (previewUploadFile = null), 300)}>
<div class="modal-box">
{#if previewUploadFile?.type === 'image'}
<img src={previewUploadFile.dataUrl} alt={previewUploadFile.name} />
{:else if previewUploadFile?.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={previewUploadFile.dataUrl} controls></video>
{/if}
</div>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="absolute top-3 right-3 btn btn-circle"></button>
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

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

View File

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

View File

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

View File

@ -56,8 +56,8 @@
{#if defaultValue != null} {#if defaultValue != null}
<option disabled selected>{defaultValue}</option> <option disabled selected>{defaultValue}</option>
{/if} {/if}
{#each Object.entries(values) as [value, label] (value)} {#each Object.entries(values) as [v, label] (v)}
<option {value}>{label}</option> <option value={v} selected={v === value}>{label}</option>
{/each} {/each}
</select> </select>
<p class="fieldset-label"> <p class="fieldset-label">

View File

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

View File

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

View File

@ -81,6 +81,15 @@ CREATE TABLE IF NOT EXISTS report (
FOREIGN KEY (reported_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 -- report status
CREATE TABLE IF NOT EXISTS report_status ( CREATE TABLE IF NOT EXISTS report_status (
status ENUM('open', 'closed'), status ENUM('open', 'closed'),

View File

@ -30,7 +30,9 @@ import {
type EditTeamReq, type EditTeamReq,
editTeam, editTeam,
type GetTeamsFullReq, type GetTeamsFullReq,
getTeamsFull getTeamsFull,
type GetTeamsByUsernameReq,
getTeamsByUsername
} from './schema/team'; } from './schema/team';
import { import {
addTeamDraft, addTeamDraft,
@ -48,6 +50,8 @@ import {
type AddTeamMemberReq, type AddTeamMemberReq,
deleteTeamMemberByTeamId, deleteTeamMemberByTeamId,
type DeleteTeamMemberByTeamIdReq, type DeleteTeamMemberByTeamIdReq,
getTeamMembersByTeamId,
type GetTeamMembersByTeamIdReq,
teamMember teamMember
} from './schema/teamMember'; } from './schema/teamMember';
import { import {
@ -93,10 +97,26 @@ import {
addUserFeedbacks, addUserFeedbacks,
type AddUserFeedbacksReq, type AddUserFeedbacksReq,
feedback, feedback,
getFeedbackByUrlHash,
type GetFeedbackByUrlHash,
getFeedbacks, getFeedbacks,
type GetFeedbacksReq type GetFeedbacksReq,
submitFeedback,
type SubmitFeedbackReq
} from './schema/feedback.ts'; } from './schema/feedback.ts';
import { addReport, type AddReportReq, getReports, type GetReportsReq, report } from './schema/report.ts'; import {
addReport,
type AddReportReq,
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 { DATABASE_URI } from 'astro:env/server';
import { import {
type GetStrikeReasonsReq, type GetStrikeReasonsReq,
@ -112,6 +132,8 @@ import {
import { import {
editStrike, editStrike,
type EditStrikeReq, type EditStrikeReq,
getStrikeByReportId,
type GetStrikeByReportIdReq,
getStrikesByTeamId, getStrikesByTeamId,
type GetStrikesByTeamIdReq, type GetStrikesByTeamIdReq,
strike strike
@ -136,6 +158,7 @@ import {
type DeleteBlockedUserReq, type DeleteBlockedUserReq,
deleteBlockedUser deleteBlockedUser
} from '@db/schema/blockedUser.ts'; } from '@db/schema/blockedUser.ts';
import { addReportAttachment, type AddReportAttachmentReq, reportAttachment } from '@db/schema/reportAttachment.ts';
export class Database { export class Database {
protected readonly db: MySql2Database<{ protected readonly db: MySql2Database<{
@ -147,6 +170,7 @@ export class Database {
blockedUser: typeof blockedUser; blockedUser: typeof blockedUser;
death: typeof death; death: typeof death;
report: typeof report; report: typeof report;
reportAttachment: typeof reportAttachment;
reportStatus: typeof reportStatus; reportStatus: typeof reportStatus;
strike: typeof strike; strike: typeof strike;
strikeReason: typeof strikeReason; strikeReason: typeof strikeReason;
@ -174,6 +198,7 @@ export class Database {
blockedUser, blockedUser,
death, death,
report, report,
reportAttachment,
reportStatus, reportStatus,
strike, strike,
strikeReason, strikeReason,
@ -223,6 +248,7 @@ export class Database {
getTeamById = (values: GetTeamByIdReq) => getTeamById(this.db, values); getTeamById = (values: GetTeamByIdReq) => getTeamById(this.db, values);
getTeamByName = (values: GetTeamByNameReq) => getTeamByName(this.db, values); getTeamByName = (values: GetTeamByNameReq) => getTeamByName(this.db, values);
getTeamByUserUuid = (values: GetTeamByUserUuidReq) => getTeamByUserUuid(this.db, values); getTeamByUserUuid = (values: GetTeamByUserUuidReq) => getTeamByUserUuid(this.db, values);
getTeamsByUsername = (values: GetTeamsByUsernameReq) => getTeamsByUsername(this.db, values);
/* team draft */ /* team draft */
addTeamDraft = (values: AddTeamDraftReq) => addTeamDraft(this.db, values); addTeamDraft = (values: AddTeamDraftReq) => addTeamDraft(this.db, values);
@ -233,6 +259,7 @@ export class Database {
/* team member */ /* team member */
addTeamMember = (values: AddTeamMemberReq) => addTeamMember(this.db, values); addTeamMember = (values: AddTeamMemberReq) => addTeamMember(this.db, values);
deleteTeamMemberByTeamId = (values: DeleteTeamMemberByTeamIdReq) => deleteTeamMemberByTeamId(this.db, values); deleteTeamMemberByTeamId = (values: DeleteTeamMemberByTeamIdReq) => deleteTeamMemberByTeamId(this.db, values);
getTeamMembersByTeamId = (values: GetTeamMembersByTeamIdReq) => getTeamMembersByTeamId(this.db, values);
/* death */ /* death */
addDeath = (values: AddDeathReq) => addDeath(this.db, values); addDeath = (values: AddDeathReq) => addDeath(this.db, values);
@ -241,7 +268,13 @@ export class Database {
/* report */ /* report */
addReport = (values: AddReportReq) => addReport(this.db, values); addReport = (values: AddReportReq) => addReport(this.db, values);
submitReport = (values: SubmitReportReq) => submitReport(this.db, values);
getReports = (values: GetReportsReq) => getReports(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);
/* report status */ /* report status */
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values); getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);
@ -255,12 +288,15 @@ export class Database {
/* strikes */ /* strikes */
editStrike = (values: EditStrikeReq) => editStrike(this.db, values); editStrike = (values: EditStrikeReq) => editStrike(this.db, values);
getStrikeByReportId = (values: GetStrikeByReportIdReq) => getStrikeByReportId(this.db, values);
getStrikesByTeamId = (values: GetStrikesByTeamIdReq) => getStrikesByTeamId(this.db, values); getStrikesByTeamId = (values: GetStrikesByTeamIdReq) => getStrikesByTeamId(this.db, values);
/* feedback */ /* feedback */
addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values); addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values);
addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values); addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values);
submitFeedback = (values: SubmitFeedbackReq) => submitFeedback(this.db, values);
getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values); getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values);
getFeedbackByUrlHash = (values: GetFeedbackByUrlHash) => getFeedbackByUrlHash(this.db, values);
/* settings */ /* settings */
getSettings = (values: GetSettingsReq) => getSettings(this.db, values); getSettings = (values: GetSettingsReq) => getSettings(this.db, values);

View File

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

View File

@ -13,7 +13,7 @@ export const report = mysqlTable('report', {
reason: varchar('reason', { length: 255 }).notNull(), reason: varchar('reason', { length: 255 }).notNull(),
body: text('body'), body: text('body'),
urlHash: varchar('url_hash', { length: 255 }).notNull(), urlHash: varchar('url_hash', { length: 255 }).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }), createdAt: timestamp('created_at', { mode: 'date' }),
reporterTeamId: int('reporter_team_id').references(() => team.id), reporterTeamId: int('reporter_team_id').references(() => team.id),
reportedTeamId: int('reported_team_id').references(() => team.id) reportedTeamId: int('reported_team_id').references(() => team.id)
}); });
@ -21,16 +21,30 @@ export const report = mysqlTable('report', {
export type AddReportReq = { export type AddReportReq = {
reason: string; reason: string;
body: string | null; body: string | null;
createdAt?: string | null; createdAt?: Date | null;
reporterTeamId?: number; reporterTeamId?: number;
reportedTeamId?: number | null; reportedTeamId?: number | null;
}; };
export type SubmitReportReq = {
urlHash: string;
reason: string;
body: string;
};
export type GetReportsReq = { export type GetReportsReq = {
reporter?: string | null; reporter?: string | null;
reported?: string | null; reported?: string | null;
}; };
export type GetReportById = {
id: number;
};
export type GetReportByUrlHash = {
urlHash: string;
};
export async function addReport(db: Database, values: AddReportReq) { export async function addReport(db: Database, values: AddReportReq) {
const urlHash = generateRandomString(16); const urlHash = generateRandomString(16);
@ -49,6 +63,17 @@ export async function addReport(db: Database, values: AddReportReq) {
return Object.assign(r[0], { url: `${BASE_PATH}/report/${urlHash}` }); return Object.assign(r[0], { url: `${BASE_PATH}/report/${urlHash}` });
} }
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) { export async function getReports(db: Database, values: GetReportsReq) {
const reporterTeam = alias(team, 'reporter'); const reporterTeam = alias(team, 'reporter');
const reportedTeam = alias(team, 'reported'); const reportedTeam = alias(team, 'reported');
@ -101,3 +126,70 @@ export async function getReports(db: Database, values: GetReportsReq) {
) )
); );
} }
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,
body: report.body,
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
}
})
.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.id, values.id));
return reports[0] ?? null;
}
export async function getReportByUrlHash(db: Database, values: GetReportByUrlHash) {
const reporterTeam = alias(team, 'reporter');
const reportedTeam = alias(team, 'reported');
const reports = await db
.select({
id: report.id,
reason: report.reason,
body: report.body,
createdAt: report.createdAt,
urlHash: report.urlHash,
reporter: {
id: reporterTeam.id,
name: reporterTeam.name
},
reported: {
id: reportedTeam.id,
name: reportedTeam.name
},
status: {
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement
}
})
.from(report)
.innerJoin(reporterTeam, eq(report.reporterTeamId, reporterTeam.id))
.leftJoin(reportedTeam, eq(report.reportedTeamId, reportedTeam.id))
.leftJoin(reportStatus, eq(report.id, reportStatus.reportId))
.where(eq(report.urlHash, values.urlHash));
return reports[0] ?? null;
}

View File

@ -0,0 +1,39 @@
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;
fileIds: number[];
};
export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) {
await db.insert(reportAttachment).values(values);
}
export async function getReportAttachments(db: Database, values: GetReportAttachmentsReq) {
return db
.select({
type: reportAttachment.type,
hash: reportAttachment.hash
})
.from(reportAttachment)
.where(eq(reportAttachment.reportId, values.reportId));
}

View File

@ -34,7 +34,8 @@ export async function getSettings(db: Database, values: GetSettingsReq) {
export async function setSettings(db: Database, values: SetSettingsReq) { export async function setSettings(db: Database, values: SetSettingsReq) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
for (const setting of values.settings) { for (const setting of values.settings) {
await tx.insert(settings) await tx
.insert(settings)
.values(setting) .values(setting)
.onDuplicateKeyUpdate({ .onDuplicateKeyUpdate({
set: { set: {

View File

@ -22,6 +22,10 @@ export type EditStrikeReq = {
strikeReasonId: number; strikeReasonId: number;
}; };
export type GetStrikeByReportIdReq = {
reportId: number;
};
export type GetStrikesByTeamIdReq = { export type GetStrikesByTeamIdReq = {
teamId: number; teamId: number;
}; };
@ -42,6 +46,15 @@ export async function editStrike(db: Database, values: EditStrikeReq) {
}); });
} }
export async function getStrikeByReportId(db: Database, values: GetStrikeByReportIdReq) {
return db.query.strike.findFirst({
with: {
strikeReason: true
},
where: eq(strike.reportId, values.reportId)
});
}
export async function getStrikesByTeamId(db: Database, values: GetStrikesByTeamIdReq) { export async function getStrikesByTeamId(db: Database, values: GetStrikesByTeamIdReq) {
return db return db
.select({ .select({

View File

@ -1,6 +1,6 @@
import { char, int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core'; import { char, int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core';
import type { MySql2Database } from 'drizzle-orm/mysql2'; 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 { teamMember } from './teamMember.ts';
import { user } from './user.ts'; import { user } from './user.ts';
import { teamDraft } from './teamDraft.ts'; import { teamDraft } from './teamDraft.ts';
@ -49,6 +49,11 @@ export type GetTeamByUserUuidReq = {
uuid: string; uuid: string;
}; };
export type GetTeamsByUsernameReq = {
username: string;
limit?: number;
};
export async function addTeam(db: Database, values: AddTeamReq) { export async function addTeam(db: Database, values: AddTeamReq) {
let color = values.color; let color = values.color;
if (!color) { if (!color) {
@ -122,10 +127,12 @@ export async function getTeams(db: Database, values: GetTeamsReq) {
.where( .where(
and( and(
values?.name != null ? like(team.name, `%${values.name}%`) : undefined, values?.name != null ? like(team.name, `%${values.name}%`) : undefined,
or(
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined, values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
) )
) )
)
.orderBy(asc(team.id)) .orderBy(asc(team.id))
.innerJoin(teamDraft, eq(team.id, teamDraft.teamId)) .innerJoin(teamDraft, eq(team.id, teamDraft.teamId))
.leftJoin(memberOneUser, eq(teamDraft.memberOneName, memberOneUser.username)) .leftJoin(memberOneUser, eq(teamDraft.memberOneName, memberOneUser.username))
@ -182,6 +189,18 @@ export async function getTeamByUserUuid(db: Database, values: GetTeamByUserUuidR
return teams[0] ?? null; 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 = [ const teamColors = [
'#cd853f', '#cd853f',
'#ff7f50', '#ff7f50',

View File

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

View File

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

View File

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

View File

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

View File

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