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

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

View File

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

View File

@ -0,0 +1,183 @@
<script lang="ts">
import { popupState } from '@components/popup/Popup.ts';
// bindings
let containerElem: HTMLDivElement;
let hiddenFileInputElem: HTMLInputElement;
let previewDialogElem: HTMLDialogElement;
// consts
const allowedImageTypes = ['image/png', 'image/jpeg', 'image/avif'];
const allowedVideoTypes = ['video/mp4'];
// types
interface Props {
maxFilesBytes: number;
}
interface UploadFile {
dataUrl: string;
name: string;
type: 'image' | 'video';
size: number;
file: File;
}
// inputs
const { maxFilesBytes }: Props = $props();
// states
let uploadFiles = $state<UploadFile[]>([]);
let previewUploadFile = $state<UploadFile | null>(null);
// lifecycle
$effect(() => {
document.dispatchEvent(
new CustomEvent('dropzoneInput', {
detail: {
files: uploadFiles.map((uf) => uf.file)
}
})
);
});
$effect(() => {
if (previewUploadFile) previewDialogElem.show();
});
// functions
function addFiles(files: FileList) {
for (const file of files) {
if (uploadFiles.find((uf) => uf.name === file.name && uf.size === file.size) !== undefined) {
continue;
}
let type: 'image' | 'video';
if (allowedImageTypes.find((mime) => mime === file.type) !== undefined) {
type = 'image';
} else if (allowedVideoTypes.find((mime) => mime === file.type) !== undefined) {
type = 'video';
} else {
$popupState = {
type: 'error',
title: 'Ungültige Datei',
message:
'Das Dateiformat wird nicht unterstützt. Nur Bilder (.png, .jpg, .jpeg, .avif) und Videos (.mp4) sind gültig'
};
return;
}
if (uploadFiles.reduce((prev, curr) => prev + curr.size, 0) + file.size > maxFilesBytes) {
$popupState = {
type: 'error',
title: 'Datei zu groß',
message: `Die Dateien dürfen insgesamt nur ${(maxFilesBytes / 1024 / 1024).toFixed(2)}MB groß sein`
};
return;
}
const reader = new FileReader();
reader.onload = () => {
uploadFiles.push({
dataUrl: reader.result as string,
name: file.name,
type: type,
size: file.size,
file: file
});
};
reader.readAsDataURL(file);
}
}
function removeFile(file: UploadFile) {
const index = uploadFiles.findIndex((uf) => uf.size === file.size && uf.name === file.name);
const uploadFile = uploadFiles.splice(index, 1).pop()!;
URL.revokeObjectURL(uploadFile.dataUrl);
}
function bytesToHumanReadable(bytes: number) {
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = parseFloat((bytes / Math.pow(1024, i)).toFixed(2));
return `${size} ${sizes[i]}`;
}
// callbacks
function onAddFiles(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
e.preventDefault();
if ((e.target as typeof e.currentTarget).files) addFiles((e.target as typeof e.currentTarget).files!);
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) addFiles(e.dataTransfer.files);
}
function onFileRemove(file: UploadFile) {
removeFile(file);
}
</script>
<fieldset class="fieldset">
<legend class="fieldset-legend">Anhänge</legend>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={containerElem}
class="h-12 rounded border border-dashed flex cursor-pointer"
class:h-26={uploadFiles.length > 0}
dropzone="copy"
onclick={() => hiddenFileInputElem.click()}
ondrop={onDrop}
ondragover={(e) => e.preventDefault()}
>
{#if uploadFiles.length === 0}
<div class="flex justify-center items-center w-full h-full">
<p>Hier Dateien droppen oder klicken um sie hochzuladen</p>
</div>
{/if}
{#each uploadFiles as uploadFile (uploadFile.name)}
<div
class="relative flex flex-col items-center w-22 h-22 m-1 cursor-default"
onclick={(e) => e.stopImmediatePropagation()}
>
<div class="cursor-zoom-in" onclick={() => (previewUploadFile = uploadFile)}>
{#if uploadFile.type === 'image'}
<img src={uploadFile.dataUrl} alt={uploadFile.name} class="w-16 h-16" />
{:else if uploadFile.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={uploadFile.dataUrl} class="w-16 h-16"></video>
{/if}
</div>
<span>{bytesToHumanReadable(uploadFile.size)}</span>
<button class="cursor-pointer" onclick={() => onFileRemove(uploadFile)}>Datei entfernen</button>
</div>
{/each}
</div>
</fieldset>
<input
bind:this={hiddenFileInputElem}
type="file"
multiple
accept={[...allowedImageTypes, ...allowedVideoTypes].join(', ')}
class="hidden absolute top-0 left-0 h-0 w-0"
onchange={onAddFiles}
/>
<dialog class="modal" bind:this={previewDialogElem} onclose={() => setTimeout(() => (previewUploadFile = null), 300)}>
<div class="modal-box">
{#if previewUploadFile?.type === 'image'}
<img src={previewUploadFile.dataUrl} alt={previewUploadFile.name} />
{:else if previewUploadFile?.type === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={previewUploadFile.dataUrl} controls></video>
{/if}
</div>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="absolute top-3 right-3 btn btn-circle"></button>
<button class="!cursor-default">close</button>
</form>
</dialog>