rewrite website

This commit is contained in:
2025-10-13 17:22:49 +02:00
parent a6d910f56a
commit 32f28e5324
263 changed files with 17904 additions and 14451 deletions

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import BitBadge from '@components/input/BitBadge.svelte';
import { Permissions } from '@util/permissions.ts';
import { type Admin, admins, deleteAdmin, editAdmin } from '@app/admin/admins/admins.ts';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import DataTable from '@components/admin/table/DataTable.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// state
let editPopupAdmin = $state(null);
let editPopupOpen = $derived(!!editPopupAdmin);
// callback
function onAdminDelete(admin: Admin) {
$confirmPopupState = {
title: 'Admin löschen',
message: 'Soll der Admin wirklich gelöscht werden?',
onConfirm: () => deleteAdmin(admin)
};
}
</script>
{#snippet permissionsBadge(permissions: number)}
<BitBadge available={Permissions.asOptions()} value={permissions} readonly />
{/snippet}
<DataTable
data={admins}
count={true}
keys={[
{ key: 'username', label: 'Username', width: 30 },
{ key: 'permissions', label: 'Berechtigungen', width: 60, transform: permissionsBadge }
]}
onEdit={(admin) => (editPopupAdmin = admin)}
onDelete={onAdminDelete}
/>
<CrudPopup
texts={{
title: 'Admin bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={editPopupAdmin}
keys={[
[
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
{ key: 'password', type: 'password', label: 'Passwort', default: null, options: { convert: (v) => v || null } }
],
[
{
key: 'permissions',
type: 'bit-badge',
label: 'Berechtigungen',
default: 0,
options: { available: Permissions.asOptions() }
}
]
]}
onSubmit={editAdmin}
bind:open={editPopupOpen}
/>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { addAdmin, fetchAdmins } from '@app/admin/admins/admins.ts';
import { Permissions } from '@util/permissions.ts';
// state
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchAdmins();
});
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Admin</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Admin erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Admin erstellen?',
confirmPopupMessage: 'Soll der Admin erstellt werden?'
}}
target={null}
keys={[
[
{ key: 'username', type: 'text', label: 'Username', options: { required: true } },
{ key: 'password', type: 'password', label: 'Passwort', options: { required: true } }
],
[
{
key: 'permissions',
type: 'bit-badge',
label: 'Berechtigungen',
default: 0,
options: { available: Permissions.asOptions() }
}
]
]}
onSubmit={addAdmin}
bind:open={createPopupOpen}
/>

View File

@@ -0,0 +1,52 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types
export type Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
export type Admin = Admins[0];
// state
export const admins = writable<Admin[]>([]);
// actions
export async function fetchAdmins() {
const { data, error } = await actions.admin.admins();
if (error) {
actionErrorPopup(error);
return;
}
admins.set(data.admins);
}
export async function addAdmin(admin: Admin & { password: string }) {
const { data, error } = await actions.admin.addAdmin(admin);
if (error) {
actionErrorPopup(error);
return;
}
addToWritableArray(admins, Object.assign(admin, { id: data.id }));
}
export async function editAdmin(admin: Admin & { password: string }) {
const { error } = await actions.admin.editAdmin(admin);
if (error) {
actionErrorPopup(error);
return;
}
updateWritableArray(admins, admin, (t) => t.id == admin.id);
}
export async function deleteAdmin(admin: Admin) {
const { error } = await actions.admin.deleteAdmin(admin);
if (error) {
actionErrorPopup(error);
return;
}
deleteFromWritableArray(admins, (t) => t.id == admin.id);
}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import {
type BlockedUser,
blockedUsers,
deleteBlockedUser,
editBlockedUser
} from '@app/admin/blockedUsers/blockedUsers.ts';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import DataTable from '@components/admin/table/DataTable.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// state
let blockedUserEditPopupBlockedUser = $state(null);
let blockedUserEditPopupOpen = $derived(!!blockedUserEditPopupBlockedUser);
// lifecycle
$effect(() => {
if (!blockedUserEditPopupOpen) blockedUserEditPopupBlockedUser = null;
});
// callback
function onBlockedUserDelete(blockedUser: BlockedUser) {
$confirmPopupState = {
title: 'Nutzer entblockieren?',
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
onConfirm: () => deleteBlockedUser(blockedUser)
};
}
</script>
<DataTable
data={blockedUsers}
count={true}
keys={[
{ key: 'uuid', label: 'UUID', width: 20, sortable: true },
{ key: 'comment', label: 'Kommentar', width: 70 }
]}
onEdit={(blockedUser) => (blockedUserEditPopupBlockedUser = blockedUser)}
onDelete={onBlockedUserDelete}
/>
<CrudPopup
texts={{
title: 'Blockierten Nutzer bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={blockedUserEditPopupBlockedUser}
keys={[
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
[{ key: 'comment', type: 'textarea', label: 'Kommentar', options: { dynamicWidth: true } }]
]}
onSubmit={editBlockedUser}
bind:open={blockedUserEditPopupOpen}
/>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { fetchBlockedUsers, addBlockedUser } from '@app/admin/blockedUsers/blockedUsers.ts';
// states
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchBlockedUsers();
});
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer blockierter Nutzer</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Blockierten Nutzer erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Nutzer blockieren',
confirmPopupMessage:
'Bist du sicher, dass der Nutzer blockiert werden soll?\nEin blockierter Nutzer kann sich nicht mehr registrieren.'
}}
target={null}
keys={[
[{ key: 'uuid', type: 'text', label: 'UUID', options: { required: true, dynamicWidth: true } }],
[{ key: 'comment', type: 'textarea', label: 'Kommentar', default: null, options: { dynamicWidth: true } }]
]}
onSubmit={addBlockedUser}
bind:open={createPopupOpen}
/>

View File

@@ -0,0 +1,52 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types
export type BlockedUsers = Exclude<ActionReturnType<typeof actions.user.blocked>['data'], undefined>['blocked'];
export type BlockedUser = BlockedUsers[0];
// state
export const blockedUsers = writable<BlockedUsers>([]);
// actions
export async function fetchBlockedUsers() {
const { data, error } = await actions.user.blocked();
if (error) {
actionErrorPopup(error);
return;
}
blockedUsers.set(data.blocked);
}
export async function addBlockedUser(blockedUser: BlockedUser) {
const { data, error } = await actions.user.addBlocked(blockedUser);
if (error) {
actionErrorPopup(error);
return;
}
addToWritableArray(blockedUsers, Object.assign(blockedUser, { id: data.id }));
}
export async function editBlockedUser(blockedUser: BlockedUser) {
const { error } = await actions.user.editBlocked(blockedUser);
if (error) {
actionErrorPopup(error);
return;
}
updateWritableArray(blockedUsers, blockedUser, (t) => t.id == blockedUser.id);
}
export async function deleteBlockedUser(blockedUser: BlockedUser) {
const { error } = await actions.user.deleteBlocked(blockedUser);
if (error) {
actionErrorPopup(error);
return;
}
deleteFromWritableArray(blockedUsers, (t) => t.id == blockedUser.id);
}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import type { Feedback } from '@app/admin/feedback/feedback.ts';
// types
interface Props {
feedback: Feedback | null;
}
// inputs
let { feedback }: Props = $props();
</script>
<div
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-10"
hidden={feedback === null}
>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (feedback = null)}>✕</button>
<div class="w-96">
<Input value={feedback?.event} label="Event" readonly />
<Input value={feedback?.title} label="Titel" readonly />
<Input value={feedback?.username} label="Nutzer" readonly />
</div>
<div class="divider divider-horizontal"></div>
<div class="w-full">
<Textarea value={feedback?.content} label="Inhalt" rows={9} readonly dynamicWidth />
</div>
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import BottomBar from './BottomBar.svelte';
import { feedbacks, fetchFeedbacks, type Feedback } from '@app/admin/feedback/feedback.ts';
import { onMount } from 'svelte';
import DataTable from '@components/admin/table/DataTable.svelte';
// consts
const dateFormat = new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// states
let activeFeedback = $state<Feedback | null>(null);
// lifecycle
onMount(() => {
fetchFeedbacks();
});
</script>
{#snippet date(value: string)}
{dateFormat.format(new Date(value))}
{/snippet}
<DataTable
data={feedbacks}
count={true}
keys={[
{ key: 'event', label: 'Event', width: 10, sortable: true },
{ key: 'username', label: 'Nutzer', width: 10, sortable: true },
{ key: 'lastChanged', label: 'Datum', width: 10, sortable: true, transform: date },
{ key: 'content', label: 'Inhalt', width: 10 }
]}
onClick={(feedback) => (activeFeedback = feedback)}
/>
<BottomBar feedback={activeFeedback} />

View File

@@ -0,0 +1,21 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
// types
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
export type Feedback = Feedbacks[0];
// state
export const feedbacks = writable<Feedbacks>([]);
// actions
export async function fetchFeedbacks(reporter?: string | null, reported?: string | null) {
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
if (error) {
actionErrorPopup(error);
return;
}
feedbacks.set(data.feedbacks);
}

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { editReport, getReportAttachments, type Report, type ReportStatus, type StrikeReasons } from './reports.ts';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import Select from '@components/input/Select.svelte';
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import Icon from '@iconify/svelte';
import UserSearch from '@components/admin/search/UserSearch.svelte';
// html bindings
let previewDialogElem: HTMLDialogElement;
// types
interface Props {
strikeReasons: StrikeReasons;
report: Report | null;
}
// inputs
let { strikeReasons, report }: Props = $props();
// states
let reportedUser = $state<{ id: number; username: string } | null>(report?.reported ?? null);
let status = $state<'open' | 'closed' | null>(null);
let notice = $state<string | null>(null);
let statement = $state<string | null>(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
const strikeReasonValues = strikeReasons.reduce(
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
{ [null]: 'Kein Vergehen' }
);
// lifetime
$effect(() => {
if (!report) return;
getReportStatus(report).then((reportStatus) => {
if (!reportStatus) return;
status = reportStatus.status;
notice = reportStatus.notice;
statement = reportStatus.statement;
});
getReportAttachments(report).then((value) => {
if (value) reportAttachments = value;
});
});
$effect(() => {
if (previewReportAttachment) previewDialogElem.show();
});
// callbacks
async function onSaveButtonClick() {
$confirmPopupState = {
title: 'Änderungen speichern?',
message: 'Sollen die Änderungen am Report gespeichert werden?',
onConfirm: async () => {
if (reportedUser?.id != report?.reported?.id) {
report!.reported = reportedUser;
await editReport(report!);
}
await editReportStatus(report!, {
status: status,
notice: notice,
statement: statement,
strikeReasonId: Number(strikeReason)
} as ReportStatus);
}
};
}
function onCopyPublicLink(urlHash: string) {
navigator.clipboard.writeText(`${document.baseURI}report/${urlHash}`);
document.activeElement?.blur();
}
</script>
<div
class="absolute bottom-2 bg-base-200 rounded-lg w-[calc(100%-1rem)] mx-2 flex px-6 py-4 gap-2"
hidden={report === null}
>
<div class="absolute right-2 top-2">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-circle btn-ghost"><Icon icon="heroicons:share" /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="menu dropdown-content bg-base-100 rounded-box z-1 p-2 shadow-sm w-max">
<li><button onclick={() => onCopyPublicLink(report?.urlHash)}>Öffentlichen Report Link kopieren</button></li>
</ul>
</div>
<button class="btn btn-sm btn-circle btn-ghost" onclick={() => (report = null)}>✕</button>
</div>
<div class="w-[34rem]">
<UserSearch value={report?.reporter.username} label="Report Ersteller" readonly mustMatch />
<UserSearch
value={report?.reported?.username}
label="Reporteter Spieler"
onSubmit={(user) => (reportedUser = user)}
/>
<Textarea bind:value={notice} label="Interne Notizen" rows={10} />
</div>
<div class="w-full">
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
<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 class="divider divider-horizontal"></div>
<div class="flex flex-col w-[42rem]">
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={7} />
<Select
bind:value={status}
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
defaultValue="Unbearbeitet"
label="Bearbeitungsstatus"
dynamicWidth
/>
<Select bind:value={strikeReason} values={strikeReasonValues} label="Vergehen" dynamicWidth></Select>
<div class="divider mt-0 mb-2"></div>
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
</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

@@ -0,0 +1,55 @@
<script lang="ts">
import BottomBar from '@app/admin/reports/BottomBar.svelte';
import { onMount } from 'svelte';
import DataTable from '@components/admin/table/DataTable.svelte';
import { type StrikeReasons, getStrikeReasons, reports } from '@app/admin/reports/reports.ts';
// consts
const dateFormat = new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// states
let strikeReasons = $state<StrikeReasons>([]);
let activeReport = $state<Report | null>(null);
// lifecycle
onMount(() => {
getStrikeReasons().then((data) => (strikeReasons = data ?? []));
});
</script>
{#snippet date(value: string)}
{value ? dateFormat.format(new Date(value)) : ''}
{/snippet}
{#snippet status(value: null | 'open' | 'closed')}
{#if value === 'open'}
<p>In Bearbeitung</p>
{:else if value === 'closed'}
<p>Bearbeitet</p>
{:else if value === null}
<p>Unbearbeitet</p>
{/if}
{/snippet}
<DataTable
data={reports}
count={true}
keys={[
{ key: 'reason', label: 'Grund' },
{ key: 'reporter.username', label: 'Report Ersteller' },
{ key: 'reported.username', label: 'Reporteter Spieler' },
{ key: 'createdAt', label: 'Datum', transform: date },
{ key: 'status.status', label: 'Bearbeitungsstatus', transform: status }
]}
onClick={(report) => (activeReport = report)}
/>
{#key activeReport}
<BottomBar {strikeReasons} report={activeReport} />
{/key}

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import Input from '@components/input/Input.svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { addReport, fetchReports } from '@app/admin/reports/reports.ts';
import Checkbox from '@components/input/Checkbox.svelte';
// states
let showDrafts = $state(false);
let reporterUsernameFilter = $state<string | null>(null);
let reportedUsernameFilter = $state<string | null>(null);
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchReports(reporterUsernameFilter, reportedUsernameFilter, showDrafts);
});
</script>
<div>
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
<legend class="fieldset-legend">Filter</legend>
<Checkbox bind:checked={showDrafts} label="Entwürfe zeigen" />
<Input bind:value={reporterUsernameFilter} label="Reporter Ersteller" />
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
</fieldset>
<div class="divider my-1"></div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Report</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Report erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Report erstellen?',
confirmPopupMessage: 'Soll der Report erstellt werden?'
}}
target={null}
keys={[
[
{
key: 'reporter',
type: 'user-search',
label: 'Report Ersteller',
default: { id: null, username: null },
options: { required: true, mustMatch: true, validate: (user) => user?.id != null }
},
{
key: 'reported',
type: 'user-search',
label: 'Reporteter Spieler',
options: { mustMatch: true, validate: (user) => user?.id != null }
}
],
[
{
key: 'reason',
type: 'text',
label: 'Grund',
options: { required: true, dynamicWidth: true, validate: (reason) => reason }
}
],
[{ key: 'body', type: 'textarea', label: 'Inhalt', default: null, options: { rows: 5, dynamicWidth: true } }],
[
{
key: 'createdAt',
type: 'checkbox',
label: 'Report kann bearbeitet werden',
default: true,
options: { convert: (v) => (v ? null : new Date().toISOString()) }
}
]
]}
onSubmit={addReport}
bind:open={createPopupOpen}
/>

View File

@@ -0,0 +1,115 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
// types
export type Reports = Exclude<ActionReturnType<typeof actions.report.reports>['data'], undefined>['reports'];
export type Report = Reports[0];
export type ReportStatus = Exclude<
Exclude<ActionReturnType<typeof actions.report.reportStatus>['data'], undefined>['reportStatus'],
null
> & { strikeReasonId: number | null };
export type StrikeReasons = Exclude<
ActionReturnType<typeof actions.report.strikeReasons>['data'],
undefined
>['strikeReasons'];
// state
export const reports = writable<Reports>([]);
// actions
export async function fetchReports(
reporterUsername: string | null,
reportedUsername: string | null,
includeDrafts: boolean
) {
const { data, error } = await actions.report.reports({
reporter: reporterUsername,
reported: reportedUsername,
includeDrafts: includeDrafts
});
if (error) {
actionErrorPopup(error);
return;
}
reports.set(data.reports);
}
export async function addReport(report: Report) {
const { data, error } = await actions.report.addReport({
reason: report.reason,
body: report.body,
createdAt: report.createdAt as unknown as string,
reporter: report.reporter.id,
reported: report.reported?.id ?? null
});
if (error) {
actionErrorPopup(error);
return;
}
reports.update((old) => {
old.push(Object.assign(report, { id: data.id, status: null }));
return old;
});
}
export async function getReportStatus(report: Report) {
const { data, error } = await actions.report.reportStatus({ reportId: report.id });
if (error) {
actionErrorPopup(error);
return;
}
return data.reportStatus;
}
export async function editReport(report: Report) {
const { error } = await actions.report.editReport({
reportId: report.id,
reported: report.reported?.id ?? null
});
if (error) {
actionErrorPopup(error);
}
}
export async function editReportStatus(report: Report, reportStatus: ReportStatus) {
const { error } = await actions.report.editReportStatus({
reportId: report.id,
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement,
strikeReasonId: reportStatus.strikeReasonId
});
if (error) {
actionErrorPopup(error);
}
}
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() {
const { data, error } = await actions.report.strikeReasons();
if (error) {
actionErrorPopup(error);
return;
}
return data.strikeReasons;
}

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { DynamicSettings } from './dynamicSettings.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import { updateSettings } from './actions.ts';
// types
interface Props {
settings: { name: string; value: string }[];
}
type SettingsInput = {
name: string;
entries: (
| {
name: string;
type: 'checkbox';
value: boolean;
onChange: (value: boolean) => void;
}
| {
name: string;
type: 'text';
value: string;
onChange: (value: string) => void;
}
| {
name: string;
type: 'textarea';
value: string;
onChange: (value: string) => void;
}
)[];
}[];
// inputs
const { settings }: Props = $props();
const dynamicSettings = new DynamicSettings(
settings.reduce((prev, curr) => Object.assign(prev, { [curr.name]: curr.value }), {})
);
let changes = $state<{ [k: string]: string | null }>(dynamicSettings.getChanges());
// consts
const settingsInput: SettingsInput = [
{
name: 'Anmeldung',
entries: [
{
name: 'Aktiviert',
type: 'checkbox',
value: dynamicSettings.signupEnabled(),
onChange: dynamicSettings.signupSetEnabled
},
{
name: 'Text unter dem Anmelde Button',
type: 'textarea',
value: dynamicSettings.signupInfoText(),
onChange: dynamicSettings.signupSetInfoText
},
{
name: 'Text, wenn die Anmeldung deaktiviert ist',
type: 'textarea',
value: dynamicSettings.signupDisabledText(),
onChange: dynamicSettings.signupSetDisabledText
},
{
name: 'Subtext, wenn die Anmeldung deaktiviert ist',
type: 'textarea',
value: dynamicSettings.signupDisabledSubtext(),
onChange: dynamicSettings.signupSetDisabledSubtext
}
]
}
];
// callbacks
function onSaveSettingsClick() {
$confirmPopupState = {
title: 'Änderungen speichern?',
message: 'Sollen die Änderungen gespeichert werden?',
onConfirm: async () => {
if (!(await updateSettings(changes))) return;
dynamicSettings.setChanges();
changes = {};
}
};
}
</script>
<div class="h-full flex flex-col items-center justify-between">
<div class="grid grid-cols-2 w-full">
{#each settingsInput as setting (setting.name)}
<div class="mx-12">
<div class="divider">{setting.name}</div>
<div class="flex flex-col gap-5">
{#each setting.entries as entry (entry.name)}
<label class="flex justify-between">
<span class="mt-[.125rem] text-sm w-1/2">{entry.name}</span>
<div class="w-1/2">
{#if entry.type === 'checkbox'}
<input
type="checkbox"
class="toggle"
onchange={(e) => {
entry.onChange(e.currentTarget.checked);
changes = dynamicSettings.getChanges();
}}
checked={entry.value}
/>
{:else if entry.type === 'text'}
<input
type="text"
class="input input-bordered"
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
value={entry.value}
/>
{:else if entry.type === 'textarea'}
<textarea
class="textarea"
value={entry.value}
onchange={(e) => {
entry.onChange(e.currentTarget.value);
changes = dynamicSettings.getChanges();
}}
></textarea>
{/if}
</div>
</label>
{/each}
</div>
</div>
{/each}
</div>
<div>
<button
class="btn btn-success mt-auto mb-8"
class:btn-disabled={Object.keys(changes).length === 0}
onclick={onSaveSettingsClick}>Speichern</button
>
</div>
</div>

View File

@@ -0,0 +1,19 @@
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
export async function updateSettings(changes: { [k: string]: string | null }) {
const { error } = await actions.settings.setSettings({
settings: Object.entries(changes).reduce(
(prev, curr) => {
prev.push({ name: curr[0], value: curr[1] });
return prev;
},
[] as { name: string; value: string | null }[]
)
});
if (error) {
actionErrorPopup(error);
return false;
}
return true;
}

View File

@@ -0,0 +1,48 @@
import { SettingKey } from '@util/settings.ts';
export class DynamicSettings {
private settings: { [k: string]: string | null };
private changedSettings: { [k: string]: string | null } = {};
constructor(settings: typeof this.settings) {
this.settings = settings;
}
private get<V extends string | boolean>(key: string, defaultValue: V): V {
const setting = this.changedSettings[key] ?? this.settings[key];
return setting != null ? JSON.parse(setting) : defaultValue;
}
private set<V extends string | boolean>(key: string, value: V | null) {
if (this.settings[key] == value) {
delete this.changedSettings[key];
} else {
this.changedSettings[key] = value != null ? JSON.stringify(value) : null;
}
}
getChanges() {
return this.changedSettings;
}
setChanges() {
this.settings = Object.assign(this.settings, this.changedSettings);
this.changedSettings = {};
}
/* signup enabled */
signupEnabled = () => this.get(SettingKey.SignupEnabled, false);
signupSetEnabled = (active: boolean) => this.set(SettingKey.SignupEnabled, active);
/* signup info text */
signupInfoText = () => this.get(SettingKey.SignupInfoMessage, '');
signupSetInfoText = (text: string) => this.set(SettingKey.SignupInfoMessage, text);
/* signup disabled text */
signupDisabledText = () => this.get(SettingKey.SignupDisabledMessage, '');
signupSetDisabledText = (text: string) => this.set(SettingKey.SignupDisabledMessage, text);
/* signup disabled subtext */
signupDisabledSubtext = () => this.get(SettingKey.SignupDisabledSubMessage, '');
signupSetDisabledSubtext = (text: string) => this.set(SettingKey.SignupDisabledSubMessage, text);
}

View File

@@ -0,0 +1,36 @@
<script>
import Icon from '@iconify/svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { addStrikeReason, fetchStrikeReasons } from '@app/admin/strikeReasons/strikeReasons.js';
// states
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchStrikeReasons();
});
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Strikegrund</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Strikegrund erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Strikegrund erstellen?',
confirmPopupMessage: 'Soll der Strikegrund erstellt werden?'
}}
target={null}
keys={[
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
]}
onSubmit={addStrikeReason}
bind:open={createPopupOpen}
/>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import DataTable from '@components/admin/table/DataTable.svelte';
import {
deleteStrikeReason,
editStrikeReason,
type StrikeReason,
strikeReasons
} from '@app/admin/strikeReasons/strikeReasons.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
// state
let editPopupStrikeReason = $state(null);
let editPopupOpen = $derived(!!editPopupStrikeReason);
// lifecycle
$effect(() => {
if (!editPopupOpen) editPopupStrikeReason = null;
});
// callback
function onBlockedUserDelete(strikeReason: StrikeReason) {
$confirmPopupState = {
title: 'Nutzer entblockieren?',
message: 'Soll der Nutzer wirklich entblockiert werden?\nDieser kann sich danach wieder registrieren.',
onConfirm: () => deleteStrikeReason(strikeReason)
};
}
</script>
<DataTable
data={strikeReasons}
count={true}
keys={[
{ key: 'name', label: 'Name', width: 20 },
{ key: 'weight', label: 'Gewichtung', width: 50, sortable: true },
{ key: 'id', label: 'Id', width: 20 }
]}
onDelete={onBlockedUserDelete}
onEdit={(strikeReason) => (editPopupStrikeReason = strikeReason)}
/>
<CrudPopup
texts={{
title: 'Strikegrund bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={editPopupStrikeReason}
keys={[
[{ key: 'name', type: 'text', label: 'Name', options: { required: true, dynamicWidth: true } }],
[{ key: 'weight', type: 'number', label: 'Gewichtung', options: { required: true, dynamicWidth: true } }]
]}
onSubmit={editStrikeReason}
bind:open={editPopupOpen}
/>

View File

@@ -0,0 +1,55 @@
import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types
export type StrikeReasons = Exclude<
ActionReturnType<typeof actions.report.strikeReasons>['data'],
undefined
>['strikeReasons'];
export type StrikeReason = StrikeReasons[0];
// state
export const strikeReasons = writable<StrikeReasons>([]);
// actions
export async function fetchStrikeReasons() {
const { data, error } = await actions.report.strikeReasons();
if (error) {
actionErrorPopup(error);
return;
}
strikeReasons.set(data.strikeReasons);
}
export async function addStrikeReason(strikeReason: StrikeReason) {
const { data, error } = await actions.report.addStrikeReason(strikeReason);
if (error) {
actionErrorPopup(error);
return;
}
addToWritableArray(strikeReasons, Object.assign(strikeReason, { id: data.id }));
}
export async function editStrikeReason(strikeReason: StrikeReason) {
const { error } = await actions.report.editStrikeReason(strikeReason);
if (error) {
actionErrorPopup(error);
return;
}
updateWritableArray(strikeReasons, strikeReason, (t) => t.id == strikeReason.id);
}
export async function deleteStrikeReason(strikeReason: StrikeReason) {
const { error } = await actions.report.deleteStrikeReason(strikeReason);
if (error) {
actionErrorPopup(error);
return;
}
deleteFromWritableArray(strikeReasons, (t) => t.id == strikeReason.id);
}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import Select from '@components/input/Select.svelte';
import { uuidFromUsername } from '@app/admin/tools/tools.ts';
// states
let edition = $state<'java' | 'bedrock'>('java');
let username = $state('');
let uuid = $state<null | string>(null);
// callbacks
async function onSubmit() {
uuid = await uuidFromUsername(edition, username);
}
</script>
<fieldset class="fieldset border border-base-200 rounded-box px-4">
<legend class="fieldset-legend">Account UUID finder</legend>
<div>
<div class="flex gap-3">
<Input bind:value={username} />
<Select bind:value={edition} values={{ java: 'Java', bedrock: 'Bedrock' }} />
</div>
<div class="flex justify-center">
<button class="btn w-4/6" class:disabled={!username} onclick={onSubmit}>UUID finden</button>
</div>
<Input bind:value={uuid} readonly />
</div>
</fieldset>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import AccountUuidFinder from '@app/admin/tools/AccountUuidFinder.svelte';
</script>
<div class="flex justify-center mt-2">
<AccountUuidFinder />
</div>

View File

@@ -0,0 +1,12 @@
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
export async function uuidFromUsername(edition: 'java' | 'bedrock', username: string) {
const { data, error } = await actions.tools.uuidFromUsername({ edition: edition, username: username });
if (error) {
actionErrorPopup(error);
return null;
}
return data.uuid;
}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import Input from '@components/input/Input.svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { addUser, fetchUsers } from '@app/admin/users/users.ts';
// states
let usernameFilter = $state<string | null>(null);
let createPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchUsers({ username: usernameFilter });
});
</script>
<div>
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
<legend class="fieldset-legend">Filter</legend>
<Input bind:value={usernameFilter} label="Username" />
</fieldset>
<div class="divider my-1"></div>
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Nutzer</span>
</button>
</div>
<CrudPopup
texts={{
title: 'Nutzer erstellen',
submitButtonTitle: 'Erstellen',
confirmPopupTitle: 'Nutzer erstellen?',
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
}}
target={null}
keys={[
[
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
],
[
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
{ key: 'telephone', type: 'tel', label: 'Telefonnummer', default: null }
],
[
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
{ key: 'uuid', type: 'text', label: 'UUID', default: null }
]
]}
onSubmit={addUser}
bind:open={createPopupOpen}
/>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import DataTable from '@components/admin/table/DataTable.svelte';
import { deleteUser, editUser, type User, users } from '@app/admin/users/users.ts';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// state
let editPopupUser = $state(null);
let editPopupOpen = $derived(!!editPopupUser);
// lifecycle
$effect(() => {
if (!editPopupOpen) editPopupUser = null;
});
// callback
function onUserDelete(user: User) {
$confirmPopupState = {
title: 'Nutzer löschen?',
message: 'Soll der Nutzer wirklich gelöscht werden?',
onConfirm: () => deleteUser(user)
};
}
</script>
<DataTable
data={users}
count={true}
keys={[
{ key: 'firstname', label: 'Vorname', width: 15, sortable: true },
{ key: 'lastname', label: 'Nachname', width: 15, sortable: true },
{ key: 'birthday', label: 'Geburtstag', width: 5, sortable: true },
{ key: 'telephone', label: 'Telefon', width: 12, sortable: true },
{ key: 'username', label: 'Username', width: 20, sortable: true },
{ key: 'uuid', label: 'UUID', width: 23 }
]}
onEdit={(user) => (editPopupUser = user)}
onDelete={onUserDelete}
/>
<CrudPopup
texts={{
title: 'Nutzer bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern?',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={editPopupUser}
keys={[
[
{ key: 'firstname', type: 'text', label: 'Vorname', options: { required: true } },
{ key: 'lastname', type: 'text', label: 'Nachname', options: { required: true } }
],
[
{ key: 'birthday', type: 'date', label: 'Geburtstag', options: { required: true } },
{ key: 'telephone', type: 'tel', label: 'Telefonnummer' }
],
[
{ key: 'username', type: 'text', label: 'Spielername', options: { required: true } },
{ key: 'uuid', type: 'text', label: 'UUID' }
]
]}
onSubmit={editUser}
bind:open={editPopupOpen}
/>

View File

@@ -0,0 +1,69 @@
import { writable } from 'svelte/store';
import { type ActionReturnType, actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
export type User = Users[0];
// state
export const users = writable<Users>([]);
// actions
export async function fetchUsers(options?: { username?: string | null }) {
const { data, error } = await actions.user.users({ username: options?.username });
if (error) {
actionErrorPopup(error);
return;
}
users.set(data.users);
}
export async function addUser(user: User) {
const { data, error } = await actions.user.addUser({
firstname: user.firstname,
lastname: user.lastname,
birthday: user.birthday,
telephone: user.telephone,
username: user.username,
edition: user.edition,
uuid: user.uuid
});
if (error) {
actionErrorPopup(error);
return;
}
addToWritableArray(users, Object.assign(user, { id: data.id }));
}
export async function editUser(user: User) {
const { error } = await actions.user.editUser({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
birthday: user.birthday,
telephone: user.telephone,
username: user.username,
edition: user.edition,
uuid: user.uuid
});
if (error) {
actionErrorPopup(error);
return;
}
updateWritableArray(users, user, (t) => t.id == user.id);
}
export async function deleteUser(user: User) {
const { error } = await actions.user.deleteUser({ id: user.id });
if (error) {
actionErrorPopup(error);
return;
}
deleteFromWritableArray(users, (t) => t.id == user.id);
}

186
src/app/layout/Menu.svelte Normal file
View File

@@ -0,0 +1,186 @@
<script lang="ts">
import MenuHome from '@assets/img/menu-home.webp';
import MenuSignup from '@assets/img/menu-signup.webp';
import MenuRules from '@assets/img/menu-rules.webp';
import MenuFaq from '@assets/img/menu-faq.webp';
import MenuFeedback from '@assets/img/menu-feedback.webp';
import MenuAdmins from '@assets/img/menu-admins.webp';
import MenuButton from '@assets/img/menu-button.webp';
import MenuInventoryBar from '@assets/img/menu-inventory-bar.webp';
import MenuSelectedFrame from '@assets/img/menu-selected-frame.webp';
import { isBrowser } from '@antfu/utils';
import { navigate } from 'astro:transitions/client';
import { onMount } from 'svelte';
// html bindings
let navElem: HTMLDivElement;
// states
let navPaths = $state([
{
name: 'Startseite',
sprite: MenuHome.src,
href: '',
active: false
},
{
name: 'Registrieren',
sprite: MenuSignup.src,
href: 'signup',
active: false
},
{
name: 'Regeln',
sprite: MenuRules.src,
href: 'rules',
active: false
},
{
name: 'FAQ',
sprite: MenuFaq.src,
href: 'faq',
active: false
},
{
name: 'Feedback & Kontakt',
sprite: MenuFeedback.src,
href: 'feedback',
active: false
},
{
name: 'Admins',
sprite: MenuAdmins.src,
href: 'admins',
active: false
}
]);
let showMenuPermanent = $state(isBrowser ? localStorage.getItem('showMenuPermanent') === 'true' : false);
let isTouch = $state(false);
let isOpen = $state(false);
let windowHeight = $state(0);
// lifecycle
$effect(() => {
localStorage.setItem('showMenuPermanent', `${showMenuPermanent}`);
});
onMount(() => {
updateActiveNavPath();
new MutationObserver(updateActiveNavPath).observe(document.head, { childList: true });
});
// functions
function updateActiveNavPath() {
for (let i = 0; i < navPaths.length; i++) {
navPaths[i].active = new URL(document.baseURI).pathname + navPaths[i].href === window.location.pathname;
}
}
</script>
<svelte:window bind:innerHeight={windowHeight} />
<svelte:body
ontouchend={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
}}
/>
<div
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50 main-menu"
bind:this={navElem}
>
<button
class={isTouch ? 'btn btn-square relative w-16 h-16' : 'btn btn-square group/menu-button relative w-16 h-16'}
onclick={() => {
if (!isTouch) {
let activePath = navPaths.find((path) => path.active);
if (activePath !== undefined) {
navigate(activePath.href);
}
showMenuPermanent = !showMenuPermanent;
}
}}
ontouchend={() => {
isTouch = true;
showMenuPermanent = !showMenuPermanent;
}}
>
<img class="absolute w-full h-full p-1 pixelated" src={MenuButton.src} alt="menu" />
<img
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated"
class:opacity-100={isOpen || (isTouch && showMenuPermanent)}
src={MenuSelectedFrame.src}
alt="menu hover"
/>
</button>
<div
class:hidden={!(isOpen || showMenuPermanent)}
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
onmouseenter={() => (isOpen = true)}
onmouseleave={() => (isOpen = false)}
role="tooltip"
>
<ul class="bg-base-200 rounded">
{#each navPaths as navPath, i (navPath.href)}
<li
class="flex justify-center tooltip"
class:tooltip-left={windowHeight > 450}
class:sm:tooltip-right={windowHeight > 450}
class:tooltip-top={windowHeight <= 450}
class:tooltip-open={isTouch || windowHeight <= 450}
data-tip={navPath.name}
>
<a
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
href={navPath.href}
onclick={() => navigate(navPath.href)}
>
<div
style="background-image: url({MenuInventoryBar.src}); background-position: -{i * 3.5}rem 0;"
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated"
></div>
<div class="absolute flex justify-center items-center w-full h-full">
<img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
</div>
<img
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10"
class:opacity-0={!navPath.active}
src={MenuSelectedFrame.src}
alt="menu hover"
/>
</a>
</li>
{/each}
</ul>
</div>
</div>
<style>
@media (max-height: 450px) {
.main-menu {
flex-direction: row;
}
.main-menu > div {
padding: 0.25rem 0 0 0.5rem;
}
.main-menu li {
display: inline-block;
&::before {
transform-origin: 0;
transform: rotate(-90deg);
margin-bottom: -0.5rem;
}
}
}
.pixelated {
image-rendering: pixelated;
}
.bg-horizontal-sprite {
background-size: auto 100%;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { onDestroy } from 'svelte';
let { start, end }: { start?: number; end: number } = $props();
let title = `Spielstart ist am ${new Date(import.meta.env.PUBLIC_START_DATE).toLocaleString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} Uhr`;
function getUntil(): [number, number, number, number] {
let diff = (end - (start || Date.now())) / 1000;
return [
Math.floor(diff / (60 * 60 * 24)),
Math.floor((diff % (60 * 60 * 24)) / (60 * 60)),
Math.floor((diff % (60 * 60)) / 60),
Math.floor(diff % 60)
];
}
let [days, hours, minutes, seconds] = $state(getUntil());
let intervalId = setInterval(() => {
[days, hours, minutes, seconds] = getUntil();
if (start) start += 1000;
}, 1000);
onDestroy(() => clearInterval(intervalId));
</script>
<div
class:hidden={days + hours + minutes + seconds < 0}
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{days};"></span>
</span>
Tage
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{hours};"></span>
</span>
Stunden
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{minutes};"></span>
</span>
Minuten
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{seconds};"></span>
</span>
Sekunden
</div>
</div>
<style>
/* Set a custom content for the countdown before selector as it only supports numbers up to 99 */
.countdown > ::before {
content: '00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A 100\A 101\A 102\A 103\A 104\A 105\A 106\A 107\A 108\A 109\A 110\A 111\A 112\A 113\A 114\A 115\A 116\A 117\A 118\A 119\A 120\A 121\A 122\A 123\A 124\A 125\A 126\A 127\A 128\A 129\A 130\A 131\A 132\A 133\A 134\A 135\A 136\A 137\A 138\A 139\A 140\A 141\A 142\A 143\A 144\A 145\A 146\A 147\A 148\A 149\A 150\A 151\A 152\A 153\A 154\A 155\A 156\A 157\A 158\A 159\A 160\A 161\A 162\A 163\A 164\A 165\A 166\A 167\A 168\A 169\A 170\A 171\A 172\A 173\A 174\A 175\A 176\A 177\A 178\A 179\A 180\A 181\A 182\A 183\A 184\A 185\A 186\A 187\A 188\A 189\A 190\A 191\A 192\A 193\A 194\A 195\A 196\A 197\A 198\A 199\A 200\A 201\A 202\A 203\A 204\A 205\A 206\A 207\A 208\A 209\A 210\A 211\A 212\A 213\A 214\A 215\A 216\A 217\A 218\A 219\A 220\A 221\A 222\A 223\A 224\A 225\A 226\A 227\A 228\A 229\A 230\A 231\A 232\A 233\A 234\A 235\A 236\A 237\A 238\A 239\A 240\A 241\A 242\A 243\A 244\A 245\A 246\A 247\A 248\A 249\A 250\A 251\A 252\A 253\A 254\A 255\A 256\A 257\A 258\A 259\A 260\A 261\A 262\A 263\A 264\A 265\A 266\A 267\A 268\A 269\A 270\A 271\A 272\A 273\A 274\A 275\A 276\A 277\A 278\A 279\A 280\A 281\A 282\A 283\A 284\A 285\A 286\A 287\A 288\A 289\A 290\A 291\A 292\A 293\A 294\A 295\A 296\A 297\A 298\A 299\A 300\A 301\A 302\A 303\A 304\A 305\A 306\A 307\A 308\A 309\A 310\A 311\A 312\A 313\A 314\A 315\A 316\A 317\A 318\A 319\A 320\A 321\A 322\A 323\A 324\A 325\A 326\A 327\A 328\A 329\A 330\A 331\A 332\A 333\A 334\A 335\A 336\A 337\A 338\A 339\A 340\A 341\A 342\A 343\A 344\A 345\A 346\A 347\A 348\A 349\A 350\A 351\A 352\A 353\A 354\A 355\A 356\A 357\A 358\A 359\A 360\A 361\A 362\A 363\A 364\A';
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import Search from '@components/admin/search/Search.svelte';
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
import Select from '@components/input/Select.svelte';
// types
interface Props {
adversary: string | null;
}
// input
const { adversary }: Props = $props();
// states
let reportTarget = $state<'player' | 'unknown'>('unknown');
let adversaryUsername = $state(adversary);
// lifecycle
$effect(() => {
dispatchAdversaryInput(adversaryUsername);
});
// functions
async function getSuggestions(query: string, _limit: number) {
const { data, error } = await actions.report.usernames({ username: query });
if (error) {
actionErrorPopup(error);
return [];
}
return data.usernames.map((u) => ({ name: u, value: u }));
}
function dispatchAdversaryInput(username: string | null) {
document.dispatchEvent(
new CustomEvent('adversaryInput', {
detail: {
adversaryUsername: username
}
})
);
}
</script>
<div class="flex justify-center gap-4">
<Select
bind:value={
() => reportTarget,
(v) => {
dispatchAdversaryInput(v === 'player' ? adversaryUsername : null);
reportTarget = v;
}
}
values={{
player: 'Ich möchte einen bestimmten Spieler reporten',
unknown: 'Ich möchte einen unbekannten Spieler reporten'
}}
dynamicWidth
/>
{#if reportTarget === 'player'}
<Search
value={adversaryUsername}
requestSuggestions={getSuggestions}
onSubmit={(value) => (adversaryUsername = value != null ? value.value : null)}
/>
{/if}
</div>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { popupState } from '@components/popup/Popup.ts';
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
// bindings
let hiddenFileInputElem: HTMLInputElement;
let previewDialogElem: HTMLDialogElement;
// 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, .webp, .avif) und Videos (.mp4, .webm) 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 ${bytesToHumanReadable(maxFilesBytes)} groß sein. Fall deine Anhänge größer sind, lade sie bitte auf einem externen Filehoster hoch (z.B. file.io, Google Drive, ...) und füge den Link zum teilen der Datei(en) zu den Report Details hinzu`
};
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
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>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { registeredPopupState } from '@app/website/signup/RegisteredPopup.ts';
import Input from '@components/input/Input.svelte';
import { onDestroy } from 'svelte';
interface Props {
discordLink: string;
paypalLink: string;
teamspeakLink: string;
startDate: string;
}
let { discordLink, paypalLink, teamspeakLink, startDate }: Props = $props();
let skin: string | null = $state(null);
let modal: HTMLDialogElement;
const cancel = registeredPopupState.subscribe(async (value) => {
if (!value) return;
modal.show();
const skinview3d = await import('skinview3d');
const skinViewer = new skinview3d.SkinViewer({
width: 200,
height: 300,
renderPaused: true
});
skinViewer.camera.rotation.x = -0.62;
skinViewer.camera.rotation.y = 0.534;
skinViewer.camera.rotation.z = 0.348;
skinViewer.camera.position.x = 30.5;
skinViewer.camera.position.y = 22.0;
skinViewer.camera.position.z = 42.0;
await skinViewer.loadSkin(`https://mc-heads.net/skin/${value.username}`);
skinViewer.render();
skin = skinViewer.canvas.toDataURL();
skinViewer.dispose();
});
onDestroy(cancel);
</script>
<dialog class="modal" bind:this={modal} onclose={() => ($registeredPopupState = null)}>
<form method="dialog" class="modal-box xl:w-5/12 max-w-10/12 z-10">
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
<p>
<b>Du hast Dich erfolgreich für Craftattack 8 registriert</b>. Spielstart ist am
<span class="underline"
>{new Date(startDate).toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}</span
>
um
<span class="underline"
>{new Date(startDate).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span
>.
</p>
<p class="text-center">Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
<p class="mt-2">
Falls du uns unterstützen möchtest, kannst du dies ganz einfach über
<a class="link" href={paypalLink} target="_blank">PayPal</a>
tun. Antworten auf häufig gestellte Fragen findest du in unserer
<a class="link" href="faq" target="_blank">FAQ</a>. Außerdem freuen wir uns, dich auf unserem
<a class="link" href={teamspeakLink} target="_blank">TeamSpeak</a>
oder in unserem
<a class="link" href={discordLink} target="_blank">Discord</a>
begrüßen zu dürfen!
</p>
<div class="divider"></div>
<div class="flex justify-around mt-2 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
<Input type="text" value={$registeredPopupState?.firstname} label="Vorname" disabled />
<Input type="text" value={$registeredPopupState?.lastname} label="Nachname" disabled />
<Input type="date" value={$registeredPopupState?.birthday} label="Geburtstag" disabled />
<Input type="tel" value={$registeredPopupState?.phone} label="Telefonnummer" disabled />
<Input type="text" value={$registeredPopupState?.username} label="Spielername" disabled />
<Input type="text" value={$registeredPopupState?.edition} label="Edition" disabled />
</div>
<div class="relative hidden md:flex justify-center w-[200px] my-4">
{#if skin}
<img class="absolute" src={skin} alt="" />
{:else}
<span class="loading loading-spinner loading-lg"></span>
{/if}
</div>
</div>
<div class="divider"></div>
<div class="flex justify-center gap-8">
<button class="btn">Weitere Person anmelden</button>
</div>
</form>
<div class="absolute w-full h-full bg-black/50"></div>
</dialog>

View File

@@ -0,0 +1,10 @@
import { atom } from 'nanostores';
export const registeredPopupState = atom<{
firstname: string;
lastname: string;
birthday: string;
phone: string;
username: string;
edition: string;
} | null>(null);

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { rulesPopupState, rulesPopupRead } from './RulesPopup.ts';
import { rules } from '../../../rules.ts';
import { popupState } from '@components/popup/Popup.ts';
import { onDestroy } from 'svelte';
const modalTimeoutSeconds = 30;
let modalElem: HTMLDialogElement;
let modalTimer = $state<ReturnType<typeof setInterval> | null>(null);
let modalSecondsOpen = $state(import.meta.env.PROD ? 0 : modalTimeoutSeconds);
const cancel = rulesPopupState.subscribe((value) => {
if (value == 'open') {
modalElem.show();
modalTimer = setInterval(() => modalSecondsOpen++, 1000);
} else if (value == 'closed') {
clearInterval(modalTimer!);
}
});
onDestroy(cancel);
</script>
<dialog
class="modal"
onclose={() => {
if ($rulesPopupState !== 'accepted') $rulesPopupState = 'closed';
}}
bind:this={modalElem}
>
<form method="dialog" class="modal-box flex flex-col max-h-[90%] max-w-[95%] md:max-w-[90%] lg:max-w-[75%]">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div class="overflow-auto mt-5">
<div class="mb-4">
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" checked />
<div class="collapse-title">
<p>0. Vorwort</p>
</div>
<div class="collapse-content">
<p>{rules.header}</p>
<p class="mt-1 text-[.75rem]">{rules.footer}</p>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
</div>
{#each rules.sections as section, i (section.title)}
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" />
<div class="collapse-title">
<p>{i + 1}. {section.title}</p>
</div>
<div class="collapse-content">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html section.content}
</div>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
{/each}
</div>
</div>
<div
class="relative w-min"
title={modalSecondsOpen < modalTimeoutSeconds
? `Die Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
: ''}
>
<div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
<div
style="width: {Math.min((modalSecondsOpen / modalTimeoutSeconds) * 100, 100)}%"
class="h-full bg-neutral"
></div>
</div>
<button
class="btn bg-transparent z-[1] relative"
class:btn-active={modalSecondsOpen < modalTimeoutSeconds}
class:cursor-default={modalSecondsOpen < modalTimeoutSeconds}
onclick={(e) => {
if (modalSecondsOpen < modalTimeoutSeconds) {
e.preventDefault();
$popupState = {
type: 'info',
title: 'Regeln',
message: 'Bitte lies die Regeln aufmerksam durch. Du kannst erst in einigen Sekunden fortfahren.'
};
return;
}
$rulesPopupRead = true;
$rulesPopupState = 'accepted';
}}>Akzeptieren</button
>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const rulesPopupState = atom<'open' | 'closed' | 'accepted'>('closed');
export const rulesPopupRead = atom(false);