add feedback and report things
This commit is contained in:
@ -6,6 +6,8 @@ ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
ADMIN_COOKIE=muelleel
|
||||
|
||||
UPLOAD_PATH=/tmp
|
||||
|
||||
YOUTUBE_INTRO_LINK=https://www.youtube-nocookie.com/embed/e78_QbTNb4s
|
||||
TEAMSPEAK_LINK=http://example.com
|
||||
DISCORD_LINK=http://example.com
|
||||
|
@ -38,6 +38,9 @@ export default defineConfig({
|
||||
ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }),
|
||||
|
||||
UPLOAD_PATH: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
MAX_UPLOAD_BYTES: envField.number({ context: 'server', access: 'secret', default: 20 * 1024 * 1024 }),
|
||||
|
||||
START_DATE: envField.string({ context: 'server', access: 'secret', default: '1970-01-01' }),
|
||||
|
||||
WEBHOOK_ENDPOINT: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
|
@ -28,6 +28,15 @@ export const feedback = {
|
||||
});
|
||||
}
|
||||
}),
|
||||
submitFeedback: defineAction({
|
||||
input: z.object({
|
||||
urlHash: z.string(),
|
||||
content: z.string()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await db.submitFeedback(input);
|
||||
}
|
||||
}),
|
||||
feedbacks: defineAction({
|
||||
handler: async (_, context) => {
|
||||
Session.actionSessionFromCookies(context.cookies, Permissions.Feedback);
|
||||
|
@ -1,10 +1,84 @@
|
||||
import { defineAction } from 'astro:actions';
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { Session } from '@util/session.ts';
|
||||
import { Permissions } from '@util/permissions.ts';
|
||||
import { db } from '@db/database.ts';
|
||||
import { z } from 'astro:schema';
|
||||
import { 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 = {
|
||||
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({
|
||||
input: z.object({
|
||||
reason: z.string(),
|
||||
@ -19,7 +93,7 @@ export const report = {
|
||||
const { id } = await db.addReport({
|
||||
reason: input.reason,
|
||||
body: input.body,
|
||||
createdAt: input.createdAt,
|
||||
createdAt: input.createdAt ? new Date(input.createdAt) : null,
|
||||
reporterTeamId: input.reporter,
|
||||
reportedTeamId: input.reported
|
||||
});
|
||||
@ -52,6 +126,9 @@ export const report = {
|
||||
handler: async (input, context) => {
|
||||
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 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({
|
||||
@ -118,5 +209,29 @@ export const report = {
|
||||
strikeReasons: await db.getStrikeReasons({})
|
||||
};
|
||||
}
|
||||
}),
|
||||
teamNamesByUsername: defineAction({
|
||||
input: z.object({
|
||||
username: z.string().nullish()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const teams = await db.getTeamsByUsername({ username: input.username ?? '', limit: 5 });
|
||||
|
||||
return {
|
||||
teamNames: teams.map((team) => ({ name: team.user.username, value: team.team.name }))
|
||||
};
|
||||
}
|
||||
}),
|
||||
teamNamesByTeamName: defineAction({
|
||||
input: z.object({
|
||||
teamName: z.string().nullish()
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const teams = await db.getTeams({ name: input.teamName, limit: 5 });
|
||||
|
||||
return {
|
||||
teamNames: teams.map((team) => ({ name: team.name, value: team.name }))
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
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;
|
||||
mustMatch?: boolean;
|
||||
|
||||
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
|
||||
requestSuggestions: (query: string, limit: number) => Promise<{ name: string; value: string }[]>;
|
||||
|
||||
onSubmit?: (value: string | null) => void;
|
||||
onSubmit?: (value: { name: string; value: string } | null) => void;
|
||||
}
|
||||
|
||||
// html bindings
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
// states
|
||||
let inputValue = $derived(value);
|
||||
let suggestions = $state<string[]>([]);
|
||||
let suggestions = $state<{ name: string; value: string }[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
// callbacks
|
||||
@ -34,9 +34,9 @@
|
||||
|
||||
suggestions = await requestSuggestions(inputValue ?? '', 5);
|
||||
|
||||
let suggestion = suggestions.find((s) => s === inputValue);
|
||||
let suggestion = suggestions.find((s) => s.name === inputValue);
|
||||
if (suggestion != null) {
|
||||
inputValue = value = suggestion;
|
||||
inputValue = value = suggestion.name;
|
||||
matched = true;
|
||||
onSubmit?.(suggestion);
|
||||
} else if (!mustMatch) {
|
||||
@ -49,8 +49,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSuggestionClick(suggestion: string) {
|
||||
inputValue = value = suggestion;
|
||||
function onSuggestionClick(name: string) {
|
||||
const suggestion = suggestions.find((s) => s.name === name)!;
|
||||
|
||||
inputValue = value = suggestion.name;
|
||||
suggestions = [];
|
||||
onSubmit?.(suggestion);
|
||||
}
|
||||
@ -77,7 +79,7 @@
|
||||
bind:value={inputValue}
|
||||
oninput={() => onSearchInput()}
|
||||
onfocusin={() => onSearchInput()}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.map((s) => s.name).join('|')})$` : undefined}
|
||||
/>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||
@ -85,8 +87,8 @@
|
||||
<li class="w-full text-left">
|
||||
<button
|
||||
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={suggestion}
|
||||
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
|
||||
title={suggestion.name}
|
||||
onclick={() => onSuggestionClick(suggestion.name)}>{suggestion.name}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
|
@ -20,9 +20,6 @@
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let teamSuggestionCache = $state<Teams>([]);
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, limit: number) {
|
||||
const { data, error } = await actions.team.teams({
|
||||
@ -35,17 +32,7 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
teamSuggestionCache = data.teams;
|
||||
return teamSuggestionCache.map((team) => team.name);
|
||||
}
|
||||
|
||||
async function getTeamByTeamName(teamName: string) {
|
||||
let team = teamSuggestionCache.find((team) => team.name === teamName);
|
||||
if (!team) {
|
||||
await getSuggestions(teamName, 5);
|
||||
return await getTeamByTeamName(teamName);
|
||||
}
|
||||
return team;
|
||||
return data.teams.map((team) => ({ name: team.name, value: team }));
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -57,5 +44,5 @@
|
||||
{required}
|
||||
{mustMatch}
|
||||
requestSuggestions={async (teamName) => getSuggestions(teamName, 5)}
|
||||
onSubmit={async (teamName) => onSubmit?.(teamName != null ? await getTeamByTeamName(teamName) : null)}
|
||||
onSubmit={async (suggestion) => onSubmit?.(suggestion != null ? suggestion.value : null)}
|
||||
/>
|
||||
|
@ -20,9 +20,6 @@
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let userSuggestionCache = $state<Users>([]);
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, limit: number) {
|
||||
const { data, error } = await actions.user.users({
|
||||
@ -35,17 +32,7 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
userSuggestionCache = data.users;
|
||||
return userSuggestionCache.map((user) => user.username);
|
||||
}
|
||||
|
||||
async function getUserByUsername(username: string) {
|
||||
let user = userSuggestionCache.find((user) => user.username === username);
|
||||
if (!user) {
|
||||
await getSuggestions(username, 5);
|
||||
return await getUserByUsername(username);
|
||||
}
|
||||
return user;
|
||||
return data.users.map((user) => ({ name: user.username, value: user }));
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -57,5 +44,5 @@
|
||||
{required}
|
||||
{mustMatch}
|
||||
requestSuggestions={async (username) => getSuggestions(username, 5)}
|
||||
onSubmit={async (username) => onSubmit?.(username != null ? await getUserByUsername(username) : null)}
|
||||
onSubmit={async (suggestion) => onSubmit?.(suggestion != null ? suggestion.value : null)}
|
||||
/>
|
||||
|
@ -56,8 +56,8 @@
|
||||
{#if defaultValue != null}
|
||||
<option disabled selected>{defaultValue}</option>
|
||||
{/if}
|
||||
{#each Object.entries(values) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{#each Object.entries(values) as [v, label] (v)}
|
||||
<option value={v} selected={v === value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="fieldset-label">
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
// callbacks
|
||||
function onModalClose() {
|
||||
$popupState?.onClose?.();
|
||||
setTimeout(() => ($popupState = null), 300);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string } | null>(null);
|
||||
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string; onClose?: () => void } | null>(
|
||||
null
|
||||
);
|
||||
|
@ -81,6 +81,15 @@ CREATE TABLE IF NOT EXISTS report (
|
||||
FOREIGN KEY (reported_team_id) REFERENCES team(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report attachment
|
||||
CREATE TABLE IF NOT EXISTS report_attachment (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('image', 'video') NOT NULL,
|
||||
hash CHAR(32) NOT NULL,
|
||||
report_id INT NOT NULL,
|
||||
FOREIGN KEY (report_id) REFERENCES report(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- report status
|
||||
CREATE TABLE IF NOT EXISTS report_status (
|
||||
status ENUM('open', 'closed'),
|
||||
|
@ -30,7 +30,9 @@ import {
|
||||
type EditTeamReq,
|
||||
editTeam,
|
||||
type GetTeamsFullReq,
|
||||
getTeamsFull
|
||||
getTeamsFull,
|
||||
type GetTeamsByUsernameReq,
|
||||
getTeamsByUsername
|
||||
} from './schema/team';
|
||||
import {
|
||||
addTeamDraft,
|
||||
@ -48,6 +50,8 @@ import {
|
||||
type AddTeamMemberReq,
|
||||
deleteTeamMemberByTeamId,
|
||||
type DeleteTeamMemberByTeamIdReq,
|
||||
getTeamMembersByTeamId,
|
||||
type GetTeamMembersByTeamIdReq,
|
||||
teamMember
|
||||
} from './schema/teamMember';
|
||||
import {
|
||||
@ -93,10 +97,26 @@ import {
|
||||
addUserFeedbacks,
|
||||
type AddUserFeedbacksReq,
|
||||
feedback,
|
||||
getFeedbackByUrlHash,
|
||||
type GetFeedbackByUrlHash,
|
||||
getFeedbacks,
|
||||
type GetFeedbacksReq
|
||||
type GetFeedbacksReq,
|
||||
submitFeedback,
|
||||
type SubmitFeedbackReq
|
||||
} from './schema/feedback.ts';
|
||||
import { addReport, type AddReportReq, getReports, type GetReportsReq, report } from './schema/report.ts';
|
||||
import {
|
||||
addReport,
|
||||
type AddReportReq,
|
||||
getReportById,
|
||||
type GetReportById,
|
||||
getReportByUrlHash,
|
||||
type GetReportByUrlHash,
|
||||
getReports,
|
||||
type GetReportsReq,
|
||||
report,
|
||||
submitReport,
|
||||
type SubmitReportReq
|
||||
} from './schema/report.ts';
|
||||
import { DATABASE_URI } from 'astro:env/server';
|
||||
import {
|
||||
type GetStrikeReasonsReq,
|
||||
@ -112,6 +132,8 @@ import {
|
||||
import {
|
||||
editStrike,
|
||||
type EditStrikeReq,
|
||||
getStrikeByReportId,
|
||||
type GetStrikeByReportIdReq,
|
||||
getStrikesByTeamId,
|
||||
type GetStrikesByTeamIdReq,
|
||||
strike
|
||||
@ -136,6 +158,7 @@ import {
|
||||
type DeleteBlockedUserReq,
|
||||
deleteBlockedUser
|
||||
} from '@db/schema/blockedUser.ts';
|
||||
import { addReportAttachment, type AddReportAttachmentReq, reportAttachment } from '@db/schema/reportAttachment.ts';
|
||||
|
||||
export class Database {
|
||||
protected readonly db: MySql2Database<{
|
||||
@ -147,6 +170,7 @@ export class Database {
|
||||
blockedUser: typeof blockedUser;
|
||||
death: typeof death;
|
||||
report: typeof report;
|
||||
reportAttachment: typeof reportAttachment;
|
||||
reportStatus: typeof reportStatus;
|
||||
strike: typeof strike;
|
||||
strikeReason: typeof strikeReason;
|
||||
@ -174,6 +198,7 @@ export class Database {
|
||||
blockedUser,
|
||||
death,
|
||||
report,
|
||||
reportAttachment,
|
||||
reportStatus,
|
||||
strike,
|
||||
strikeReason,
|
||||
@ -223,6 +248,7 @@ export class Database {
|
||||
getTeamById = (values: GetTeamByIdReq) => getTeamById(this.db, values);
|
||||
getTeamByName = (values: GetTeamByNameReq) => getTeamByName(this.db, values);
|
||||
getTeamByUserUuid = (values: GetTeamByUserUuidReq) => getTeamByUserUuid(this.db, values);
|
||||
getTeamsByUsername = (values: GetTeamsByUsernameReq) => getTeamsByUsername(this.db, values);
|
||||
|
||||
/* team draft */
|
||||
addTeamDraft = (values: AddTeamDraftReq) => addTeamDraft(this.db, values);
|
||||
@ -233,6 +259,7 @@ export class Database {
|
||||
/* team member */
|
||||
addTeamMember = (values: AddTeamMemberReq) => addTeamMember(this.db, values);
|
||||
deleteTeamMemberByTeamId = (values: DeleteTeamMemberByTeamIdReq) => deleteTeamMemberByTeamId(this.db, values);
|
||||
getTeamMembersByTeamId = (values: GetTeamMembersByTeamIdReq) => getTeamMembersByTeamId(this.db, values);
|
||||
|
||||
/* death */
|
||||
addDeath = (values: AddDeathReq) => addDeath(this.db, values);
|
||||
@ -241,7 +268,13 @@ export class Database {
|
||||
|
||||
/* report */
|
||||
addReport = (values: AddReportReq) => addReport(this.db, values);
|
||||
submitReport = (values: SubmitReportReq) => submitReport(this.db, values);
|
||||
getReports = (values: GetReportsReq) => getReports(this.db, values);
|
||||
getReportById = (values: GetReportById) => getReportById(this.db, values);
|
||||
getReportByUrlHash = (values: GetReportByUrlHash) => getReportByUrlHash(this.db, values);
|
||||
|
||||
/* report attachment */
|
||||
addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values);
|
||||
|
||||
/* report status */
|
||||
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);
|
||||
@ -255,12 +288,15 @@ export class Database {
|
||||
|
||||
/* strikes */
|
||||
editStrike = (values: EditStrikeReq) => editStrike(this.db, values);
|
||||
getStrikeByReportId = (values: GetStrikeByReportIdReq) => getStrikeByReportId(this.db, values);
|
||||
getStrikesByTeamId = (values: GetStrikesByTeamIdReq) => getStrikesByTeamId(this.db, values);
|
||||
|
||||
/* feedback */
|
||||
addFeedback = (values: AddFeedbackReq) => addFeedback(this.db, values);
|
||||
addUserFeedbacks = (values: AddUserFeedbacksReq) => addUserFeedbacks(this.db, values);
|
||||
submitFeedback = (values: SubmitFeedbackReq) => submitFeedback(this.db, values);
|
||||
getFeedbacks = (values: GetFeedbacksReq) => getFeedbacks(this.db, values);
|
||||
getFeedbackByUrlHash = (values: GetFeedbackByUrlHash) => getFeedbackByUrlHash(this.db, values);
|
||||
|
||||
/* settings */
|
||||
getSettings = (values: GetSettingsReq) => getSettings(this.db, values);
|
||||
|
@ -12,7 +12,7 @@ export const feedback = mysqlTable('feedback', {
|
||||
title: varchar('title', { length: 255 }),
|
||||
content: text('content'),
|
||||
urlHash: varchar('url_hash', { length: 255 }).unique().notNull(),
|
||||
lastChanged: timestamp('last_changed', { mode: 'date' }).notNull().defaultNow(),
|
||||
lastChanged: timestamp('last_changed', { mode: 'date' }).notNull().defaultNow().onUpdateNow(),
|
||||
userId: int('user_id').references(() => user.id)
|
||||
});
|
||||
|
||||
@ -27,8 +27,17 @@ export type AddUserFeedbacksReq = {
|
||||
uuids: string[];
|
||||
};
|
||||
|
||||
export type SubmitFeedbackReq = {
|
||||
urlHash: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type GetFeedbacksReq = {};
|
||||
|
||||
export type GetFeedbackByUrlHash = {
|
||||
urlHash: string;
|
||||
};
|
||||
|
||||
export async function addFeedback(db: Database, values: AddFeedbackReq) {
|
||||
return db.insert(feedback).values({
|
||||
event: values.event,
|
||||
@ -58,8 +67,16 @@ export async function addUserFeedbacks(db: Database, values: AddUserFeedbacksReq
|
||||
return userFeedbacks;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
|
||||
export async function submitFeedback(db: Database, values: SubmitFeedbackReq) {
|
||||
return db
|
||||
.update(feedback)
|
||||
.set({
|
||||
content: values.content
|
||||
})
|
||||
.where(eq(feedback.urlHash, values.urlHash));
|
||||
}
|
||||
|
||||
export async function getFeedbacks(db: Database, _values: GetFeedbacksReq) {
|
||||
return db
|
||||
.select({
|
||||
id: feedback.id,
|
||||
@ -73,3 +90,9 @@ export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
|
||||
.from(feedback)
|
||||
.leftJoin(user, eq(feedback.userId, user.id));
|
||||
}
|
||||
|
||||
export async function getFeedbackByUrlHash(db: Database, values: GetFeedbackByUrlHash) {
|
||||
return db.query.feedback.findFirst({
|
||||
where: eq(feedback.urlHash, values.urlHash)
|
||||
});
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ export const report = mysqlTable('report', {
|
||||
reason: varchar('reason', { length: 255 }).notNull(),
|
||||
body: text('body'),
|
||||
urlHash: varchar('url_hash', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'date' }),
|
||||
reporterTeamId: int('reporter_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 = {
|
||||
reason: string;
|
||||
body: string | null;
|
||||
createdAt?: string | null;
|
||||
createdAt?: Date | null;
|
||||
reporterTeamId?: number;
|
||||
reportedTeamId?: number | null;
|
||||
};
|
||||
|
||||
export type SubmitReportReq = {
|
||||
urlHash: string;
|
||||
reason: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type GetReportsReq = {
|
||||
reporter?: string | null;
|
||||
reported?: string | null;
|
||||
};
|
||||
|
||||
export type GetReportById = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type GetReportByUrlHash = {
|
||||
urlHash: string;
|
||||
};
|
||||
|
||||
export async function addReport(db: Database, values: AddReportReq) {
|
||||
const urlHash = generateRandomString(16);
|
||||
|
||||
@ -49,6 +63,17 @@ export async function addReport(db: Database, values: AddReportReq) {
|
||||
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) {
|
||||
const reporterTeam = alias(team, 'reporter');
|
||||
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) {
|
||||
return db.transaction(async (tx) => {
|
||||
for (const setting of values.settings) {
|
||||
await tx.insert(settings)
|
||||
await tx
|
||||
.insert(settings)
|
||||
.values(setting)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
|
@ -22,6 +22,10 @@ export type EditStrikeReq = {
|
||||
strikeReasonId: number;
|
||||
};
|
||||
|
||||
export type GetStrikeByReportIdReq = {
|
||||
reportId: number;
|
||||
};
|
||||
|
||||
export type GetStrikesByTeamIdReq = {
|
||||
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) {
|
||||
return db
|
||||
.select({
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { char, int, mysqlTable, timestamp, varchar } from 'drizzle-orm/mysql-core';
|
||||
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
||||
import { aliasedTable, and, asc, desc, eq, like, sql } from 'drizzle-orm';
|
||||
import { aliasedTable, and, asc, desc, eq, like, or, sql } from 'drizzle-orm';
|
||||
import { teamMember } from './teamMember.ts';
|
||||
import { user } from './user.ts';
|
||||
import { teamDraft } from './teamDraft.ts';
|
||||
@ -49,6 +49,11 @@ export type GetTeamByUserUuidReq = {
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export type GetTeamsByUsernameReq = {
|
||||
username: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export async function addTeam(db: Database, values: AddTeamReq) {
|
||||
let color = values.color;
|
||||
if (!color) {
|
||||
@ -122,8 +127,10 @@ export async function getTeams(db: Database, values: GetTeamsReq) {
|
||||
.where(
|
||||
and(
|
||||
values?.name != null ? like(team.name, `%${values.name}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
||||
or(
|
||||
values?.username != null ? like(teamDraft.memberOneName, `%${values.username}%`) : undefined,
|
||||
values?.username != null ? like(teamDraft.memberTwoName, `%${values.username}%`) : undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(team.id))
|
||||
@ -182,6 +189,18 @@ export async function getTeamByUserUuid(db: Database, values: GetTeamByUserUuidR
|
||||
return teams[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getTeamsByUsername(db: Database, values: GetTeamsByUsernameReq) {
|
||||
return db
|
||||
.select({
|
||||
user: user,
|
||||
team: team
|
||||
})
|
||||
.from(user)
|
||||
.where(like(user.username, `%${values.username}%`))
|
||||
.innerJoin(teamMember, eq(user.id, teamMember.userId))
|
||||
.innerJoin(team, eq(teamMember.teamId, team.id));
|
||||
}
|
||||
|
||||
const teamColors = [
|
||||
'#cd853f',
|
||||
'#ff7f50',
|
||||
|
@ -24,6 +24,10 @@ export type DeleteTeamMemberByTeamIdReq = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type GetTeamMembersByTeamIdReq = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
|
||||
const teamMemberIds = await db.insert(teamMember).values(values).$returningId();
|
||||
|
||||
@ -33,3 +37,13 @@ export async function addTeamMember(db: Database, values: AddTeamMemberReq) {
|
||||
export async function deleteTeamMemberByTeamId(db: Database, values: DeleteTeamMemberByTeamIdReq) {
|
||||
await db.delete(teamMember).where(eq(teamMember.teamId, values.teamId));
|
||||
}
|
||||
|
||||
export async function getTeamMembersByTeamId(db: Database, values: GetTeamMembersByTeamIdReq) {
|
||||
return db
|
||||
.select({
|
||||
user: user
|
||||
})
|
||||
.from(teamMember)
|
||||
.innerJoin(user, eq(teamMember.userId, user.id))
|
||||
.where(eq(teamMember.teamId, values.teamId));
|
||||
}
|
||||
|
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