show report attachments in admin ui
All checks were successful
deploy / build-and-deploy (/testvaro, /opt/website-test, website-test) (push) Successful in 22s
deploy / build-and-deploy (/varo, /opt/website, website) (push) Successful in 15s

This commit is contained in:
2025-06-22 16:29:48 +02:00
parent 1a81b5fb06
commit 9a6e44b2d5
8 changed files with 133 additions and 10 deletions

View File

@ -66,8 +66,20 @@ export const report = {
const hash = md5Hash.digest('hex'); const hash = md5Hash.digest('hex');
const filePath = path.join(UPLOAD_PATH!, hash); const filePath = path.join(UPLOAD_PATH!, hash);
let type: 'image' | 'video';
if (allowedImageTypes.includes(file.type)) {
type = 'image';
} else if (allowedVideoTypes.includes(file.type)) {
type = 'video';
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Invalid file type'
});
}
await tx.addReportAttachment({ await tx.addReportAttachment({
type: 'video', type: type,
hash: hash, hash: hash,
reportId: report.id reportId: report.id
}); });
@ -198,6 +210,18 @@ export const report = {
}; };
} }
}), }),
reportAttachments: defineAction({
input: z.object({
reportId: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
reportAttachments: (await db.getReportAttachments(input)) ?? []
};
}
}),
addStrikeReason: defineAction({ addStrikeReason: defineAction({
input: z.object({ input: z.object({
name: z.string(), name: z.string(),

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { editReport, type Report, type ReportStatus, type StrikeReasons } from './reports.ts'; import { editReport, getReportAttachments, type Report, type ReportStatus, type StrikeReasons } from './reports.ts';
import Input from '@components/input/Input.svelte'; import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte'; import Textarea from '@components/input/Textarea.svelte';
import Select from '@components/input/Select.svelte'; import Select from '@components/input/Select.svelte';
@ -7,6 +7,9 @@
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts'; import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts'; import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// html bindings
let previewDialogElem: HTMLDialogElement;
// types // types
interface Props { interface Props {
strikeReasons: StrikeReasons; strikeReasons: StrikeReasons;
@ -18,11 +21,15 @@
// states // states
let reportedTeam = $state<{ id: number; name: string } | null>(report?.reported ?? null); let reportedTeam = $state<{ id: number; name: string } | null>(report?.reported ?? null);
let status = $state<'open' | 'closed' | null>(null); let status = $state<'open' | 'closed' | null>(null);
let notice = $state<string | null>(null); let notice = $state<string | null>(null);
let statement = $state<string | null>(null); let statement = $state<string | null>(null);
let strikeReason = $state<string | null>(String(report?.strike?.strikeReasonId ?? null)); let strikeReason = $state<string | null>(String(report?.strike?.strikeReasonId ?? null));
let reportAttachments = $state<{ type: 'image' | 'video'; hash: string }[]>([]);
let previewReportAttachment = $state<{ type: 'image' | 'video'; hash: string } | null>(null);
// consts // consts
const strikeReasonValues = strikeReasons.reduce( const strikeReasonValues = strikeReasons.reduce(
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }), (prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
@ -40,6 +47,14 @@
notice = reportStatus.notice; notice = reportStatus.notice;
statement = reportStatus.statement; statement = reportStatus.statement;
}); });
getReportAttachments(report).then((value) => {
if (value) reportAttachments = value;
});
});
$effect(() => {
if (previewReportAttachment) previewDialogElem.show();
}); });
// callbacks // callbacks
@ -71,16 +86,38 @@
<div class="w-[34rem]"> <div class="w-[34rem]">
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch /> <TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
<TeamSearch value={report?.reported?.name} label="Reportetes Team" onSubmit={(team) => (reportedTeam = team)} /> <TeamSearch value={report?.reported?.name} label="Reportetes Team" onSubmit={(team) => (reportedTeam = team)} />
<Textarea bind:value={notice} label="Interne Notizen" rows={8} /> <Textarea bind:value={notice} label="Interne Notizen" rows={10} />
</div> </div>
<div class="divider divider-horizontal"></div>
<div class="w-full"> <div class="w-full">
<Input value={report?.reason} label="Grund" readonly dynamicWidth /> <Input value={report?.reason} label="Grund" readonly dynamicWidth />
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} /> <Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={9} />
<fieldset class="fieldset">
<legend class="fieldset-legend">Anhänge</legend>
<div class="h-16.5 rounded border border-dashed flex">
{#each reportAttachments as reportAttachment (reportAttachment.hash)}
<div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="cursor-zoom-in" onclick={() => (previewReportAttachment = reportAttachment)}>
{#if reportAttachment.type === 'image'}
<img
src={location.pathname + '/attachment/' + reportAttachment.hash}
alt={reportAttachment.hash}
class="w-16 h-16"
/>
{:else if reportAttachment.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={location.pathname + '/attachment/' + reportAttachment.hash} class="w-16 h-16"></video>
{/if}
</div>
</div>
{/each}
</div>
</fieldset>
</div> </div>
<div class="divider divider-horizontal"></div> <div class="divider divider-horizontal"></div>
<div class="flex flex-col w-[42rem]"> <div class="flex flex-col w-[42rem]">
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} /> <Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={7} />
<Select <Select
bind:value={status} bind:value={status}
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }} values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
@ -93,3 +130,22 @@
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button> <button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
</div> </div>
</div> </div>
<dialog
class="modal"
bind:this={previewDialogElem}
onclose={() => setTimeout(() => (previewReportAttachment = null), 300)}
>
<div class="modal-box">
{#if previewReportAttachment?.type === 'image'}
<img src={location.pathname + '/attachment/' + previewReportAttachment.hash} alt={previewReportAttachment.hash} />
{:else if previewReportAttachment?.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={location.pathname + '/attachment/' + previewReportAttachment.hash} controls></video>
{/if}
</div>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="absolute top-3 right-3 btn btn-circle"></button>
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -84,6 +84,18 @@ export async function editReportStatus(report: Report, reportStatus: ReportStatu
} }
} }
export async function getReportAttachments(report: Report) {
const { data, error } = await actions.report.reportAttachments({
reportId: report.id
});
if (error) {
actionErrorPopup(error);
return;
}
return data.reportAttachments;
}
export async function getStrikeReasons() { export async function getStrikeReasons() {
const { data, error } = await actions.report.strikeReasons(); const { data, error } = await actions.report.strikeReasons();
if (error) { if (error) {

View File

@ -3,7 +3,6 @@
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts'; import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
// bindings // bindings
let containerElem: HTMLDivElement;
let hiddenFileInputElem: HTMLInputElement; let hiddenFileInputElem: HTMLInputElement;
let previewDialogElem: HTMLDialogElement; let previewDialogElem: HTMLDialogElement;
@ -123,7 +122,6 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
bind:this={containerElem}
class="h-12 rounded border border-dashed flex cursor-pointer" class="h-12 rounded border border-dashed flex cursor-pointer"
class:h-26={uploadFiles.length > 0} class:h-26={uploadFiles.length > 0}
dropzone="copy" dropzone="copy"

View File

@ -162,7 +162,13 @@ 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'; import {
addReportAttachment,
type AddReportAttachmentReq,
getReportAttachments,
type GetReportAttachmentsReq,
reportAttachment
} from '@db/schema/reportAttachment.ts';
export class Database { export class Database {
protected readonly db: MySql2Database<{ protected readonly db: MySql2Database<{
@ -280,6 +286,7 @@ export class Database {
/* report attachment */ /* report attachment */
addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values); addReportAttachment = (values: AddReportAttachmentReq) => addReportAttachment(this.db, values);
getReportAttachments = (values: GetReportAttachmentsReq) => getReportAttachments(this.db, values);
/* report status */ /* report status */
getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values); getReportStatus = (values: GetReportStatusReq) => getReportStatus(this.db, values);

View File

@ -21,7 +21,6 @@ export type AddReportAttachmentReq = {
export type GetReportAttachmentsReq = { export type GetReportAttachmentsReq = {
reportId: number; reportId: number;
fileIds: number[];
}; };
export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) { export async function addReportAttachment(db: Database, values: AddReportAttachmentReq) {

View File

@ -0,0 +1,27 @@
import type { APIRoute } from 'astro';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import fs from 'node:fs';
import path from 'node:path';
import { UPLOAD_PATH } from 'astro:env/server';
export const GET: APIRoute = async ({ params, cookies }) => {
Session.actionSessionFromCookies(cookies, Permissions.Reports);
if (!UPLOAD_PATH) return new Response(null, { status: 404 });
const fileHash = params.fileHash as string;
const filePath = path.join(UPLOAD_PATH, fileHash);
if (!fs.existsSync(filePath)) return new Response(null, { status: 404 });
const fileStat = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath);
return new Response(fileStream as any, {
status: 200,
headers: {
'Content-Length': fileStat.size.toString()
}
});
};