add feedback and report things
This commit is contained in:
@ -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
|
||||||
|
@ -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 }),
|
||||||
|
@ -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);
|
||||||
|
@ -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 }))
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
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
|
||||||
|
>
|
183
src/app/website/report/Dropzone.svelte
Normal file
183
src/app/website/report/Dropzone.svelte
Normal 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>
|
@ -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}
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
@ -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">
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
// callbacks
|
// callbacks
|
||||||
function onModalClose() {
|
function onModalClose() {
|
||||||
|
$popupState?.onClose?.();
|
||||||
setTimeout(() => ($popupState = null), 300);
|
setTimeout(() => ($popupState = null), 300);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -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
|
||||||
|
);
|
||||||
|
@ -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'),
|
||||||
|
@ -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);
|
||||||
|
@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
39
src/db/schema/reportAttachment.ts
Normal file
39
src/db/schema/reportAttachment.ts
Normal 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));
|
||||||
|
}
|
@ -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: {
|
||||||
|
@ -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({
|
||||||
|
@ -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,8 +127,10 @@ 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,
|
||||||
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
or(
|
||||||
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
||||||
|
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(team.id))
|
.orderBy(asc(team.id))
|
||||||
@ -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',
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
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>
|
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>
|
Reference in New Issue
Block a user