From 3713c7eaba9339b10c3b4365bf59f9115139b2b1 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Sat, 30 Sep 2023 01:01:26 +0200 Subject: [PATCH] add option to create a report via the admin panel --- src/lib/components/Input/Search.svelte | 78 ++++++++++ src/routes/admin/reports/+page.svelte | 52 ++++--- src/routes/admin/reports/+server.ts | 47 ++++++ src/routes/admin/reports/HeaderBar.svelte | 33 +++++ .../admin/reports/NewReportModal.svelte | 137 ++++++++++++++++++ src/routes/admin/users/+server.ts | 28 +++- src/routes/report/+server.ts | 7 +- 7 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/Input/Search.svelte create mode 100644 src/routes/admin/reports/HeaderBar.svelte create mode 100644 src/routes/admin/reports/NewReportModal.svelte diff --git a/src/lib/components/Input/Search.svelte b/src/lib/components/Input/Search.svelte new file mode 100644 index 0000000..e0588d5 --- /dev/null +++ b/src/lib/components/Input/Search.svelte @@ -0,0 +1,78 @@ +<script lang="ts"> + export let id: string | null = null; + export let value = ''; + export let suggestionRequired = false; + export let searchSuggestionFunc: ( + input: string + ) => Promise<{ name: string; value: string }[]> = () => Promise.resolve([]); + export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md'; + export let label: string | null = null; + export let required = false; + + let inputValue: string; + let searchSuggestions: { name: string; value: string }[] = []; + $: if (!suggestionRequired) value = inputValue; +</script> + +<div class="relative"> + <div> + {#if label} + <label class="label" for={id}> + <span class="label-text"> + {label} + {#if required} + <span class="text-red-700">*</span> + {/if} + </span> + </label> + {/if} + <input + type="search" + class="input input-bordered w-full" + class:input-xs={size === 'xs'} + class:input-sm={size === 'sm'} + class:input-md={size === 'md'} + class:input-lg={size === 'md'} + {id} + {required} + bind:value={inputValue} + on:input={() => { + value = ''; + searchSuggestionFunc(inputValue).then((v) => { + searchSuggestions = v; + let searchSuggestionValue = v.find((v) => v.name === inputValue); + if (searchSuggestionValue !== undefined) { + value = searchSuggestionValue.value; + searchSuggestions = []; + } + }); + }} + pattern={suggestionRequired ? `${value ? inputValue : 'a^'}` : null} + /> + </div> + + {#if inputValue && searchSuggestions.length !== 0} + <ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box"> + {#each searchSuggestions as searchSuggestion} + <li class="w-full text-left"> + <button + class="block w-full overflow-hidden text-ellipsis whitespace-nowrap" + title="{searchSuggestion.name} ({searchSuggestion.value})" + on:click|preventDefault={() => { + inputValue = searchSuggestion.name; + value = searchSuggestion.value; + searchSuggestions = []; + }}>{searchSuggestion.name}</button + > + </li> + {/each} + </ul> + {/if} +</div> + +{#if inputValue && searchSuggestions.length !== 0} + <button + class="absolute top-0 left-0 z-10 w-full h-full cursor-default" + on:click={() => (searchSuggestions = [])} + /> +{/if} diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte index e667846..66ffcb8 100644 --- a/src/routes/admin/reports/+page.svelte +++ b/src/routes/admin/reports/+page.svelte @@ -8,6 +8,9 @@ import Input from '$lib/components/Input/Input.svelte'; import Textarea from '$lib/components/Input/Textarea.svelte'; import { reportCount } from '$lib/stores'; + import HeaderBar from './HeaderBar.svelte'; + import { IconOutline } from 'svelte-heros-v2'; + import NewReportModal from './NewReportModal.svelte'; export let data: PageData; @@ -47,29 +50,12 @@ } let saveActiveReportChangesModal: HTMLDialogElement; + let newReportModal: HTMLDialogElement; </script> <div class="h-screen flex flex-row"> <div class="w-full flex flex-col"> - <form class="flex flex-row justify-center space-x-4 mx-4 my-2"> - <Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}> - <span slot="label">Report Ersteller</span> - </Input> - <Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}> - <span slot="label">Reportete Spieler</span> - </Input> - <Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}> - <option value="none">Unbearbeitet</option> - <option value="review">In Bearbeitung</option> - <option value={null}>Unbearbeitet & In Bearbeitung</option> - <option value="reviewed">Bearbeitet</option> - </Select> - <Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}> - <option value={false}>Erstellt</option> - <option value={true}>Entwurf</option> - <option value={null}>Erstellt & Entwurf</option> - </Select> - </form> + <HeaderBar bind:reportFilter /> <hr class="divider my-1 mx-8 border-none" /> <table class="table table-fixed h-fit"> <colgroup> @@ -124,6 +110,16 @@ <td>{report.draft ? 'Entwurf' : 'Erstellt'}</td> </tr> {/each} + <tr> + <td colspan="100"> + <div class="flex justify-center items-center"> + <button class="btn btn-sm" on:click={() => newReportModal.show()}> + <IconOutline name="plus-outline" /> + <span>Neuer Report</span> + </button> + </div> + </td> + </tr> </tbody> </table> </div> @@ -162,7 +158,7 @@ > <Textarea label="Interne Notizen" - readonly={activeReport.auditor === null && activeReport.notice === null} + readonly={activeReport.status === 'none'} rows={1} bind:value={activeReport.notice} /> @@ -175,13 +171,15 @@ > <Textarea label="(Öffentliche) Report Antwort" - readonly={activeReport.auditor === null && activeReport.notice === null} + readonly={activeReport.status === 'none'} rows={3} bind:value={activeReport.statement} /> </div> <Select label="Bearbeitungsstatus" size="sm" bind:value={activeReport.status}> - <option value="none" disabled={activeReport.auditor != null || activeReport.notice} + <option + value="none" + disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement} >Unbearbeitet</option > <option value="review">In Bearbeitung</option> @@ -227,3 +225,13 @@ <button>close</button> </form> </dialog> + +<dialog class="modal" bind:this={newReportModal}> + <NewReportModal + on:submit={(e) => { + if (!e.detail.draft) $reportCount += 1; + currentPageReports = [e.detail, ...currentPageReports]; + activeReport = currentPageReports[0]; + }} + /> +</dialog> diff --git a/src/routes/admin/reports/+server.ts b/src/routes/admin/reports/+server.ts index 784d161..420fa26 100644 --- a/src/routes/admin/reports/+server.ts +++ b/src/routes/admin/reports/+server.ts @@ -5,6 +5,7 @@ import { Admin, Report, User } from '$lib/server/database'; import type { Attributes } from 'sequelize'; import { Op } from 'sequelize'; import { env } from '$env/dynamic/private'; +import crypto from 'crypto'; export const POST = (async ({ request, cookies }) => { if (getSession(cookies, { permissions: [Permissions.ReportRead] }) == null) { @@ -104,3 +105,49 @@ export const PATCH = (async ({ request, cookies }) => { return new Response(); }) satisfies RequestHandler; + +export const PUT = (async ({ request, cookies, url }) => { + if (getSession(cookies, { permissions: [Permissions.ReportWrite] }) == null) { + return new Response(null, { + status: 401 + }); + } + + const data: { + reporter: string; + reported: string; + reason: string; + body: string | null; + } = await request.json(); + + if ( + data.reporter == null || + data.reported == null || + data.reason == null || + data.body === undefined + ) + return new Response(null, { status: 400 }); + + const reporter = await User.findOne({ where: { uuid: data.reporter } }); + const reported = await User.findOne({ where: { uuid: data.reported } }); + + if (reporter == null || reported == null) return new Response(null, { status: 400 }); + + const report = await Report.create({ + subject: data.reason, + body: data.body, + draft: data.body === null, + status: 'none', + url_hash: crypto.randomBytes(18).toString('hex'), + completed: false, + reporter_id: reporter.id, + reported_id: reported.id + }); + report.dataValues.reporter = await User.findOne({ where: { id: report.reporter_id } }); + report.dataValues.reported = await User.findOne({ where: { id: report.reported_id } }); + report.dataValues.auditor = null; + + return new Response(JSON.stringify(report), { + status: 201 + }); +}) satisfies RequestHandler; diff --git a/src/routes/admin/reports/HeaderBar.svelte b/src/routes/admin/reports/HeaderBar.svelte new file mode 100644 index 0000000..945019f --- /dev/null +++ b/src/routes/admin/reports/HeaderBar.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import Select from '$lib/components/Input/Select.svelte'; + import Input from '$lib/components/Input/Input.svelte'; + + export let reportFilter = { + reporter: null, + reported: null, + status: null, + draft: false + }; +</script> + +<div class="flex flex-row justify-center items-end"> + <form class="flex flex-row justify-center space-x-4 mx-4 my-2"> + <Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}> + <span slot="label">Report Ersteller</span> + </Input> + <Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}> + <span slot="label">Reportete Spieler</span> + </Input> + <Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}> + <option value="none">Unbearbeitet</option> + <option value="review">In Bearbeitung</option> + <option value={null}>Unbearbeitet & In Bearbeitung</option> + <option value="reviewed">Bearbeitet</option> + </Select> + <Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}> + <option value={false}>Erstellt</option> + <option value={true}>Entwurf</option> + <option value={null}>Erstellt & Entwurf</option> + </Select> + </form> +</div> diff --git a/src/routes/admin/reports/NewReportModal.svelte b/src/routes/admin/reports/NewReportModal.svelte new file mode 100644 index 0000000..0c91750 --- /dev/null +++ b/src/routes/admin/reports/NewReportModal.svelte @@ -0,0 +1,137 @@ +<script lang="ts"> + import Input from '$lib/components/Input/Input.svelte'; + import { env } from '$env/dynamic/public'; + import Textarea from '$lib/components/Input/Textarea.svelte'; + import Search from '$lib/components/Input/Search.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + let reporter: string; + let reported: string; + let reason = ''; + let body = ''; + + async function usernameSuggestions(username: string): Promise<{ name: string; value: string }[]> { + const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, { + method: 'POST', + body: JSON.stringify({ + limit: 6, + search: username, + slim: true + }) + }); + const json: { username: string; uuid: string }[] = await response.json(); + return json.map((v) => { + return { name: v.username, value: v.uuid }; + }); + } + + async function newReport() { + const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, { + method: 'PUT', + body: JSON.stringify({ + reporter: reporter, + reported: reported, + reason: reason, + body: body || null + }) + }); + if (response.ok) dispatch('submit', await response.json()); + } + + let globalCloseForm: HTMLFormElement; + + let reportForm: HTMLFormElement; + let confirmDialog: HTMLDialogElement; +</script> + +<form method="dialog" class="modal-box" bind:this={reportForm}> + <button + class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" + on:click|preventDefault={() => globalCloseForm.submit()}>✕</button + > + <h3 class="font-roboto text-xl">Neuer Report</h3> + <div class="space-y-2 mt-2 px-1 max-h-[70vh] overflow-y-scroll"> + <div> + <Search + size="sm" + suggestionRequired={true} + searchSuggestionFunc={usernameSuggestions} + label="Reporter" + required={true} + bind:value={reporter} + /> + <Search + size="sm" + suggestionRequired={true} + searchSuggestionFunc={usernameSuggestions} + label="Reporteter User" + required={true} + bind:value={reported} + /> + </div> + <div class="divider mx-4 pt-3" /> + <Input type="text" bind:value={reason} required={true} pickyWidth={false}> + <span slot="label">Report Grund</span> + </Input> + <div> + <Textarea rows={4} label="Details über den Report Grund" bind:value={body} /> + </div> + </div> + <div class="flex flex-row space-x-2 mt-6"> + <Input + type="submit" + value="Erstellen" + on:click={(e) => { + if (reportForm.checkValidity()) { + e.detail.preventDefault(); + confirmDialog.show(); + } + }} + /> + <Input + type="submit" + value="Abbrechen" + on:click={(e) => { + e.detail.preventDefault(); + globalCloseForm.submit(); + }} + /> + </div> +</form> +<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}> + <button>close</button> +</form> + +<dialog class="modal" bind:this={confirmDialog}> + <form method="dialog" class="modal-box"> + <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button> + <h3 class="font-roboto text-xl mb-2">Report Erstellen?</h3> + {#if body} + <p> + Dadurch, dass bereits Details über den Report Grund hinzugefügt wurden, ist es nach dem + Erstellen nicht mehr möglich, den Report Inhalt zu ändern + </p> + {:else} + <p> + Der Report wird als Entwurf gespeichert und kann nach dem Erstellen über den Report-Link + bearbeitet werden + </p> + {/if} + <div class="flex flex-row space-x-2 mt-6"> + <Input + type="submit" + value="Erstellen" + on:click={async () => { + await newReport(); + globalCloseForm.submit(); + }} + /> + <Input type="submit" value="Abbrechen" /> + </div> + </form> + <form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]"> + <button>close</button> + </form> +</dialog> diff --git a/src/routes/admin/users/+server.ts b/src/routes/admin/users/+server.ts index 62ee1f7..ff659f4 100644 --- a/src/routes/admin/users/+server.ts +++ b/src/routes/admin/users/+server.ts @@ -1,7 +1,8 @@ import { getSession } from '$lib/server/session'; import { Permissions } from '$lib/permissions'; import type { RequestHandler } from '@sveltejs/kit'; -import { Admin, User } from '$lib/server/database'; +import { User } from '$lib/server/database'; +import { type Attributes, Op } from 'sequelize'; export const POST = (async ({ request, cookies }) => { if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) { @@ -10,11 +11,28 @@ export const POST = (async ({ request, cookies }) => { }); } - const data = await request.json(); - const limit = data['limit'] || 100; - const from = data['from'] || 0; + const data: { + limit: number | null; + from: number | null; + search: string | null; + slim: boolean | null; + } = await request.json(); - const users = await User.findAll({ offset: from, limit: limit }); + const usersFindOptions: Attributes<User> = {}; + if (data.search) { + Object.assign(usersFindOptions, { + [Op.or]: { + username: { [Op.like]: `%${data.search}%` }, + uuid: { [Op.like]: `%${data.search}%` } + } + }); + } + const users = await User.findAll({ + where: usersFindOptions, + attributes: data.slim ? ['username', 'uuid'] : undefined, + offset: data.from || 0, + limit: data.limit || 100 + }); return new Response(JSON.stringify(users)); }) satisfies RequestHandler; diff --git a/src/routes/report/+server.ts b/src/routes/report/+server.ts index 9bc074f..e72f9c6 100644 --- a/src/routes/report/+server.ts +++ b/src/routes/report/+server.ts @@ -1,11 +1,12 @@ import type { RequestHandler } from '@sveltejs/kit'; import { Report, User } from '$lib/server/database'; import * as crypto from 'crypto'; +import { env } from '$env/dynamic/public'; export const POST = (async ({ request, url }) => { const data: { reporter: string; reported: string; reason: string } = await request.json(); - if (data.reporter == undefined || data.reported == undefined || data.reason == undefined) + if (data.reporter == null || data.reported == null || data.reason == null) return new Response(null, { status: 400 }); const reporter = await User.findOne({ where: { uuid: data.reporter } }); @@ -25,7 +26,9 @@ export const POST = (async ({ request, url }) => { }); return new Response( - JSON.stringify({ url: `${url.toString().replace(/\/$/, '')}/${report.url_hash}` }), + JSON.stringify({ + url: `${url.protocol}//${url.host}${env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}` + }), { status: 201 }