initial commit
Some checks failed
deploy / build-and-deploy (push) Failing after 21s

This commit is contained in:
2025-05-18 13:16:20 +02:00
commit 60f3f8a096
148 changed files with 17900 additions and 0 deletions

View File

@ -0,0 +1,63 @@
<script lang="ts">
import Badges from './Badges.svelte';
import { Permissions } from '@util/permissions.ts';
import type { Admin } from './types.ts';
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
import { admins } from './state.ts';
import Icon from '@iconify/svelte';
import { editAdmin } from './actions.ts';
// consts
const availablePermissionBadges = {
[Permissions.Admin.value]: 'Admin',
[Permissions.Users.value]: 'Users',
[Permissions.Reports.value]: 'Reports',
[Permissions.Feedback.value]: 'Feedback',
[Permissions.Settings.value]: 'Settings',
[Permissions.Tools.value]: 'Tools'
};
// states
let editAdminPopupAdmin = $state<Admin | null>(null);
</script>
<div class="h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<tr>
<th style="width: 5%">#</th>
<th style="width: 30%">Benutzername</th>
<th style="width: 60%">Berechtigungen</th>
<th style="width: 5%"></th>
</tr>
</thead>
<tbody>
{#each $admins as admin, i (admin.id)}
<tr class="hover:bg-base-200">
<td>{i + 1}</td>
<td>{admin.username}</td>
<td>
<Badges available={availablePermissionBadges} set={new Permissions(admin.permissions).toNumberArray()} />
</td>
<td>
<button class="cursor-pointer" onclick={() => (editAdminPopupAdmin = admin)}>
<Icon icon="heroicons:pencil-square" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#key editAdminPopupAdmin}
<CreateOrEditPopup
popupTitle="Admin bearbeiten"
submitButtonTitle="Admin bearbeiten"
confirmPopupTitle="Admin bearbeiten"
confirmPopupMessage="Bist du sicher, dass du den Admin bearbeiten möchtest?"
admin={editAdminPopupAdmin}
open={editAdminPopupAdmin != null}
onSubmit={editAdmin}
/>
{/key}

View File

@ -0,0 +1,49 @@
<script lang="ts">
interface Props {
available: { [k: number]: string };
set: number[];
onUpdate?: (set: number[]) => void;
}
// inputs
let { available, set, onUpdate }: Props = $props();
let reactiveSet = $state(set);
// callbacks
function onOptionSelect(e: Event) {
const value = Number((e.target as HTMLSelectElement).value);
reactiveSet.push(value);
onUpdate?.(reactiveSet);
(e.target as HTMLSelectElement).value = '-';
}
function onBadgeRemove(badge: number) {
const index = reactiveSet.indexOf(badge);
if (index !== -1) {
reactiveSet.splice(index, 1);
}
}
</script>
<div class="flex flex-col gap-4">
{#if onUpdate}
<select class="select select-xs w-min" onchange={onOptionSelect}>
<option selected hidden>-</option>
{#each Object.entries(available) as [value, badge] (value)}
<option {value} hidden={reactiveSet.indexOf(Number(value)) !== -1}>{badge}</option>
{/each}
</select>
{/if}
<div class="flex flow flex-wrap gap-2">
{#each reactiveSet as badge (badge)}
<div class="badge badge-outline gap-1">
{#if onUpdate}
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(badge)}>✕</button>
{/if}
<span>{available[badge]}</span>
</div>
{/each}
</div>
</div>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import Input from '@components/input/Input.svelte';
import Badges from './Badges.svelte';
import type { Admin } from './types.ts';
import { Permissions } from '@util/permissions.ts';
import Password from '@components/input/Password.svelte';
// html bindings
let modal: HTMLDialogElement;
let modalForm: HTMLFormElement;
// types
interface Props {
popupTitle: string;
submitButtonTitle: string;
confirmPopupTitle: string;
confirmPopupMessage: string;
admin: Admin | null;
open: boolean;
onSubmit: (admin: Admin & { password: string }) => void;
onClose?: () => void;
}
// consts
const availablePermissionBadges = {
[Permissions.Admin.value]: 'Admin',
[Permissions.Users.value]: 'Users',
[Permissions.Reports.value]: 'Reports',
[Permissions.Feedback.value]: 'Feedback',
[Permissions.Settings.value]: 'Settings',
[Permissions.Tools.value]: 'Tools'
};
// inputs
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, admin, open, onSubmit, onClose }: Props =
$props();
// states
let username = $state<string | null>(admin?.username ?? null);
let password = $state<string | null>(null);
let permissions = $state<number | null>(admin?.permissions ?? 0);
let submitEnabled = $derived(!!(username && password));
// lifecycle
$effect(() => {
if (open) modal.show();
});
// callbacks
function onBadgesUpdate(newPermissions: number[]) {
permissions = new Permissions(newPermissions).value;
}
function onSaveButtonClick() {
$confirmPopupState = {
title: confirmPopupTitle,
message: confirmPopupMessage,
onConfirm: () => {
onSubmit({
id: admin?.id ?? -1,
username: username!,
password: password!,
permissions: permissions!
});
}
};
}
function onCancelButtonClick(e: Event) {
e.preventDefault();
modalForm.submit();
}
</script>
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
<form method="dialog" class="modal-box w-min" bind:this={modalForm}>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
<div class="space-y-5">
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
<div class="w-full gap-x-4 gap-y-2">
<div class="w-[20rem]">
<Input type="text" bind:value={username} label="Username" required />
<Password bind:value={password} label="Password" required />
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Berechtigungen</legend>
{#key admin}
<Badges
available={availablePermissionBadges}
set={new Permissions(permissions).toNumberArray()}
onUpdate={onBadgesUpdate}
/>
{/key}
</fieldset>
</div>
<div>
<button
class="btn btn-success"
class:disabled={!submitEnabled}
disabled={!submitEnabled}
onclick={onSaveButtonClick}>{submitButtonTitle}</button
>
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
</div>
</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,34 @@
<script lang="ts">
import { addAdmin, fetchAdmins } from './actions.ts';
import Icon from '@iconify/svelte';
import { onMount } from 'svelte';
import CreateOrEditPopup from '@app/admin/admins/CreateOrEditPopup.svelte';
// lifecycle
onMount(() => {
fetchAdmins();
});
// states
let newTeamPopupOpen = $state(false);
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Admin</span>
</button>
</div>
{#key newTeamPopupOpen}
<CreateOrEditPopup
popupTitle="Admin erstellen"
submitButtonTitle="Admin erstellen"
confirmPopupTitle="Admin erstellen"
confirmPopupMessage="Bist du sicher, dass du den Admin erstellen möchtest?"
admin={null}
open={newTeamPopupOpen}
onSubmit={addAdmin}
onClose={() => (newTeamPopupOpen = false)}
/>
{/key}

View File

@ -0,0 +1,41 @@
import type { Admin } from './types.ts';
import { actions } from 'astro:actions';
import { admins } from './state.ts';
import { actionErrorPopup } from '@util/action.ts';
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;
}
admins.update((old) => {
old.push(Object.assign(admin, { id: data.id }));
return old;
});
}
export async function editAdmin(admin: Admin & { password: string }) {
const { error } = await actions.admin.editAdmin(admin);
if (error) {
actionErrorPopup(error);
return;
}
admins.update((old) => {
const index = old.findIndex((a) => a.id == admin.id);
old[index] = admin;
return old;
});
}

View File

@ -0,0 +1,4 @@
import type { Admin } from './types.ts';
import { writable } from 'svelte/store';
export const admins = writable<Admin[]>([]);

View File

@ -0,0 +1,4 @@
import type { ActionReturnType, actions } from 'astro:actions';
export type Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
export type Admin = Admins[0];

View File

@ -0,0 +1,29 @@
<script lang="ts">
import type { Feedback } from './types.ts';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
// 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?.user?.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,53 @@
<script lang="ts">
import BottomBar from './BottomBar.svelte';
import SortableTr from '@components/admin/table/SortableTr.svelte';
import SortableTh from '@components/admin/table/SortableTh.svelte';
import { feedbacks } from './state.ts';
import { fetchFeedbacks } from './actions.ts';
import { onMount } from 'svelte';
import type { Feedback } from './types.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 activeFeedback = $state<Feedback | null>(null);
// lifecycle
onMount(() => {
fetchFeedbacks();
});
</script>
<div class="min-h-[70vh] max-h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<SortableTr data={feedbacks}>
<SortableTh style="width: 5%">#</SortableTh>
<SortableTh style="width: 10%">Event</SortableTh>
<SortableTh style="width: 20%" key="user.username">Nutzer</SortableTh>
<SortableTh style="width: 20%" key="lastChanged">Datum</SortableTh>
<SortableTh style="width: 45%">Inhalt</SortableTh>
</SortableTr>
</thead>
<tbody>
{#each $feedbacks as feedback, i (feedback.id)}
<tr class="hover:bg-base-200" onclick={() => (activeFeedback = feedback)}>
<td>{(i + 1)}</td>
<td>{feedback.event}</td>
<td>{feedback.user?.username}</td>
<td>{dateFormat.format(new Date(feedback.lastChanged))}</td>
<td>{feedback.content}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<BottomBar feedback={activeFeedback} />

View File

@ -0,0 +1,13 @@
import { actions } from 'astro:actions';
import { feedbacks } from './state.ts';
import { actionErrorPopup } from '@util/action.ts';
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,4 @@
import { writable } from 'svelte/store';
import type { Feedbacks } from './types.ts';
export const feedbacks = writable<Feedbacks>([]);

View File

@ -0,0 +1,4 @@
import type { ActionReturnType, actions } from 'astro:actions';
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
export type Feedback = Feedbacks[0];

View File

@ -0,0 +1,87 @@
<script lang="ts">
import type { Report, ReportStatus, StrikeReasons } from './types.ts';
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import Select from '@components/input/Select.svelte';
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
import { editReportStatus, getReportStatus } from '@app/admin/reports/actions.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// types
interface Props {
strikeReasons: StrikeReasons;
report: Report | null;
}
// inputs
let { strikeReasons, report }: Props = $props();
// states
let status = $state<'open' | 'closed' | null>(null);
let notice = $state<string | null>(null);
let statement = $state<string | null>(null);
// consts
const strikeReasonValues = strikeReasons.reduce(
(prev, curr) => Object.assign(prev, { [curr.id]: `${curr.name} (${curr.weight})` }),
{}
);
// lifetime
$effect(() => {
if (!report) return;
getReportStatus(report).then((reportStatus) => {
if (!reportStatus) return;
status = reportStatus.status;
notice = reportStatus.notice;
statement = reportStatus.statement;
});
});
// callbacks
async function onSaveButtonClick() {
$confirmPopupState = {
title: 'Änderungen speichern?',
message: 'Sollen die Änderungen am Report gespeichert werden?',
onConfirm: async () =>
editReportStatus(report!, {
status: status,
notice: notice,
statement: statement,
strikeId: null
} as ReportStatus)
};
}
</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}
>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => (report = null)}>✕</button>
<div class="w-[34rem]">
<TeamSearch value={report?.reporter.name} label="Report Team" readonly mustMatch />
<TeamSearch value={report?.reported?.name} label="Reportetes Team" />
<Textarea bind:value={notice} label="Interne Notizen" rows={8} />
</div>
<div class="divider divider-horizontal"></div>
<div class="w-full">
<Input value={report?.reason} label="Grund" readonly dynamicWidth />
<Textarea value={report?.body} label="Inhalt" readonly dynamicWidth rows={12} />
</div>
<div class="divider divider-horizontal"></div>
<div class="flex flex-col w-[42rem]">
<Textarea bind:value={statement} label="Öffentliche Report Antwort" dynamicWidth rows={5} />
<Select
values={{ open: 'In Bearbeitung', closed: 'Bearbeitet' }}
defaultValue="Unbearbeitet"
label="Bearbeitungsstatus"
dynamicWidth
/>
<Select bind:value={status} values={strikeReasonValues} defaultValue="" label="Vergehen" dynamicWidth></Select>
<div class="divider mt-0 mb-2"></div>
<button class="btn mt-auto" onclick={onSaveButtonClick}>Speichern</button>
</div>
</div>

View File

@ -0,0 +1,97 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
import Textarea from '@components/input/Textarea.svelte';
import Checkbox from '@components/input/Checkbox.svelte';
import type { Report } from './types.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// html bindings
let modal: HTMLDialogElement;
let modalForm: HTMLFormElement;
// types
interface Props {
open: boolean;
onSubmit: (report: Report) => void;
onClose?: () => void;
}
// input
let { open, onSubmit, onClose }: Props = $props();
// form
let reason = $state<string | null>(null);
let body = $state<string | null>(null);
let editable = $state<boolean>(true);
let reporter = $state<Report['reporter'] | null>(null);
let reported = $state<Report['reported'] | null>(null);
let submitEnabled = $derived(!!(reason && reporter));
// lifecycle
$effect(() => {
if (open) modal.show();
});
// callbacks
async function onSaveButtonClick(e: Event) {
e.preventDefault();
$confirmPopupState = {
title: 'Report erstellen',
message: 'Bist du sicher, dass du den Report erstellen möchtest?',
onConfirm: () => {
modalForm.submit();
onSubmit({
id: -1,
reason: reason!,
body: body!,
reporter: reporter!,
reported: reported!,
createdAt: editable ? null : new Date().toISOString(),
status: null
});
}
};
}
function onCancelButtonClick(e: Event) {
e.preventDefault();
modalForm.submit();
}
</script>
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
<form method="dialog" class="modal-box" bind:this={modalForm}>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
<div class="space-y-5">
<h3 class="text-xt font-geist font-bold">Neuer Report</h3>
<div>
<div class="grid grid-cols-2 gap-4">
<TeamSearch label="Report Team" required mustMatch onSubmit={(team) => (reporter = team)} />
<TeamSearch label="Reportetes Team" mustMatch onSubmit={(team) => (reported = team)} />
</div>
<div class="grid grid-cols-1">
<Input label="Grund" bind:value={reason} required dynamicWidth />
<Textarea label="Inhalt" bind:value={body} rows={5} dynamicWidth />
</div>
<div class="grid grid-cols-1 mt-2">
<Checkbox label="Report kann bearbeitet werden" bind:checked={editable} />
</div>
</div>
<div>
<button
class="btn btn-success"
class:disabled={!submitEnabled}
disabled={!submitEnabled}
onclick={onSaveButtonClick}>Erstellen</button
>
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
</div>
</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,50 @@
<script lang="ts">
import SortableTr from '@components/admin/table/SortableTr.svelte';
import { reports } from './state.ts';
import type { Report, StrikeReasons } from './types.ts';
import SortableTh from '@components/admin/table/SortableTh.svelte';
import BottomBar from '@app/admin/reports/BottomBar.svelte';
import { onMount } from 'svelte';
import { getStrikeReasons } from '@app/admin/reports/actions.ts';
// states
let strikeReasons = $state<StrikeReasons>([]);
let activeReport = $state<Report | null>(null);
// lifecycle
onMount(() => {
getStrikeReasons().then((data) => (strikeReasons = data ?? []));
});
</script>
<div class="h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<SortableTr data={reports}>
<SortableTh style="width: 5%">#</SortableTh>
<SortableTh>Grund</SortableTh>
<SortableTh>Report Team</SortableTh>
<SortableTh>Reportetes Team</SortableTh>
<SortableTh>Datum</SortableTh>
<SortableTh>Bearbeitungsstatus</SortableTh>
<SortableTh style="width: 5%"></SortableTh>
</SortableTr>
</thead>
<tbody>
{#each $reports as report, i (report.id)}
<tr class="hover:bg-base-200" onclick={() => (activeReport = report)}>
<td>{i + 1}</td>
<td>{report.reason}</td>
<td>{report.reporter.name}</td>
<td>{report.reported?.name}</td>
<td>{report.createdAt}</td>
<td>{report.status?.status}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#key activeReport}
<BottomBar {strikeReasons} report={activeReport} />
{/key}

View File

@ -0,0 +1,34 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import Input from '@components/input/Input.svelte';
import { addReport, fetchReports } from '@app/admin/reports/actions.ts';
import CreatePopup from '@app/admin/reports/CreatePopup.svelte';
// states
let reporterUsernameFilter = $state<string | null>(null);
let reportedUsernameFilter = $state<string | null>(null);
let newReportPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchReports(reporterUsernameFilter, reportedUsernameFilter);
});
</script>
<div>
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
<legend class="fieldset-legend">Filter</legend>
<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={() => (newReportPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Report</span>
</button>
</div>
{#key newReportPopupOpen}
<CreatePopup open={newReportPopupOpen} onSubmit={addReport} onClose={() => (newReportPopupOpen = false)} />
{/key}

View File

@ -0,0 +1,67 @@
import { actions } from 'astro:actions';
import { reports } from './state.ts';
import { actionErrorPopup } from '@util/action.ts';
import type { Report, ReportStatus } from './types.ts';
export async function fetchReports(reporterUsername: string | null, reportedUsername: string | null) {
const { data, error } = await actions.report.reports({ reporter: reporterUsername, reported: reportedUsername });
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,
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 editReportStatus(report: Report, reportStatus: ReportStatus) {
const { error } = await actions.report.editReportStatus({
reportId: report.id,
status: reportStatus.status,
notice: reportStatus.notice,
statement: reportStatus.statement,
strikeId: reportStatus.strikeId
});
if (error) {
actionErrorPopup(error);
}
}
export async function getStrikeReasons() {
const { data, error } = await actions.report.strikeReasons();
if (error) {
actionErrorPopup(error);
return;
}
return data.strikeReasons;
}

View File

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { Reports } from './types.ts';
export const reports = writable<Reports>([]);

View File

@ -0,0 +1,14 @@
import type { ActionReturnType, actions } from 'astro:actions';
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
>;
export type StrikeReasons = Exclude<
ActionReturnType<typeof actions.report.strikeReasons>['data'],
undefined
>['strikeReasons'];

View File

@ -0,0 +1,137 @@
<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, 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">{entry.name}</span>
{#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}
</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,44 @@
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 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,102 @@
<script lang="ts">
import UserSearch from '@components/admin/search/UserSearch.svelte';
import Input from '@components/input/Input.svelte';
import type { Team } from '@app/admin/teams/types.ts';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
// html bindings
let modal: HTMLDialogElement;
let modalForm: HTMLFormElement;
// types
interface Props {
popupTitle: string;
submitButtonTitle: string;
confirmPopupTitle: string;
confirmPopupMessage: string;
team: Team | null;
open: boolean;
onSubmit: (team: Team) => void;
onClose?: () => void;
}
// inputs
let { popupTitle, submitButtonTitle, confirmPopupTitle, confirmPopupMessage, team, open, onSubmit, onClose }: Props =
$props();
// states
let name = $state<string | null>(team?.name ?? null);
let color = $state<string | null>(team?.color ?? null);
let lastJoined = $state<string | null>(team?.lastJoined ?? null);
let memberOne = $state<Team['memberOne']>(team?.memberOne ?? ({ username: null } as unknown as Team['memberOne']));
let memberTwo = $state<Team['memberOne']>(team?.memberTwo ?? ({ username: null } as unknown as Team['memberOne']));
let submitEnabled = $derived(!!(name && color && memberOne.username && memberTwo.username));
// lifecycle
$effect(() => {
if (open) modal.show();
});
// callbacks
async function onSaveButtonClick(e: Event) {
e.preventDefault();
$confirmPopupState = {
title: confirmPopupTitle,
message: confirmPopupMessage,
onConfirm: () => {
modalForm.submit();
onSubmit({
id: team?.id ?? -1,
name: name!,
color: color!,
lastJoined: lastJoined!,
memberOne: memberOne!,
memberTwo: memberTwo!
});
}
};
}
function onCancelButtonClick(e: Event) {
e.preventDefault();
modalForm.submit();
}
</script>
<dialog class="modal" bind:this={modal} onclose={() => setTimeout(() => onClose?.(), 300)}>
<form method="dialog" class="modal-box overflow-visible" bind:this={modalForm}>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
<div class="space-y-5">
<h3 class="text-xl font-geist font-bold">{popupTitle}</h3>
<div class="w-full flex flex-col">
<div class="grid grid-cols-2 gap-4">
<Input type="color" label="Farbe" bind:value={color} />
<Input type="text" label="Name" bind:value={name} />
</div>
<div class="grid grid-cols-2 gap-4">
<UserSearch label="Spieler 1" bind:value={memberOne.username} required mustMatch />
<UserSearch label="Spieler 2" bind:value={memberTwo.username} required />
</div>
<div class="grid grid-cols-2 gap-4">
<Input type="date" label="Zuletzt gejoined" bind:value={lastJoined}></Input>
</div>
</div>
<div>
<button
class="btn btn-success"
class:disabled={!submitEnabled}
disabled={!submitEnabled}
onclick={onSaveButtonClick}>{submitButtonTitle}</button
>
<button class="btn btn-error" type="button" onclick={onCancelButtonClick}>Abbrechen</button>
</div>
</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,43 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import Icon from '@iconify/svelte';
import { addTeam, fetchTeams } from './actions.ts';
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
// states
let teamNameFilter = $state<string | null>(null);
let memberUsernameFilter = $state<string | null>(null);
let newTeamPopupOpen = $state(false);
// lifecycle
$effect(() => {
fetchTeams(teamNameFilter, memberUsernameFilter);
});
</script>
<div>
<fieldset class="fieldset border border-base-content/50 rounded-box p-2">
<legend class="fieldset-legend">Filter</legend>
<Input bind:value={teamNameFilter} label="Team Name" />
<Input bind:value={memberUsernameFilter} label="Spieler Username" />
</fieldset>
<div class="divider my-1"></div>
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neues Team</span>
</button>
</div>
{#key newTeamPopupOpen}
<CreateOrEditPopup
popupTitle="Neues Team"
submitButtonTitle="Team erstellen"
confirmPopupTitle="Team erstellen"
confirmPopupMessage="Bist du sicher, dass du das Team erstellen möchtest?"
team={null}
open={newTeamPopupOpen}
onSubmit={addTeam}
onClose={() => (newTeamPopupOpen = false)}
/>
{/key}

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { teams } from './state.ts';
import type { Team } from './types.ts';
import { editTeam } from './actions.ts';
import Icon from '@iconify/svelte';
import SortableTr from '@components/admin/table/SortableTr.svelte';
import SortableTh from '@components/admin/table/SortableTh.svelte';
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
// state
let editTeamPopupTeam = $state<Team | null>(null);
</script>
<div class="h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<SortableTr data={teams}>
<SortableTh style="width: 5%">#</SortableTh>
<SortableTh style="width: 5%">Farbe</SortableTh>
<SortableTh style="width: 25%" key="name">Name</SortableTh>
<SortableTh style="width: 30%" key="memberOne.username">Spieler 1</SortableTh>
<SortableTh style="width: 30%" key="memberTwo.username">Spieler 2</SortableTh>
<SortableTh style="width: 5%"></SortableTh>
</SortableTr>
</thead>
<tbody>
{#each $teams as team, i (team.id)}
<tr class="hover:bg-base-200">
<td>{i + 1}</td>
<td>
<div class="rounded-sm w-3 h-3" style="background-color: {team.color}"></div>
</td>
<td>{team.name}</td>
{#if team.memberOne.id != null}
<td>{team.memberOne.username}</td>
{:else}
<td class="text-base-content/30">{team.memberOne.username}</td>
{/if}
{#if team.memberTwo.id != null}
<td>{team.memberTwo.username}</td>
{:else}
<td class="text-base-content/30">{team.memberTwo.username}</td>
{/if}
<td>
<button class="cursor-pointer" onclick={() => (editTeamPopupTeam = team)}>
<Icon icon="heroicons:pencil-square" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#key editTeamPopupTeam}
<CreateOrEditPopup
popupTitle="Team bearbeiten"
submitButtonTitle="Team bearbeiten"
confirmPopupTitle="Team bearbeiten"
confirmPopupMessage="Bist du sicher, dass du das Team bearbeiten möchtest?"
team={editTeamPopupTeam}
open={editTeamPopupTeam != null}
onSubmit={editTeam}
/>
{/key}

View File

@ -0,0 +1,41 @@
import { actions } from 'astro:actions';
import { teams } from './state.ts';
import type { Team } from './types.ts';
import { actionErrorPopup } from '@util/action.ts';
export async function fetchTeams(name: string | null, username: string | null) {
const { data, error } = await actions.team.teams({ name: name, username: username });
if (error) {
actionErrorPopup(error);
return;
}
teams.set(data.teams);
}
export async function addTeam(team: Team) {
const { data, error } = await actions.team.addTeam(team);
if (error) {
actionErrorPopup(error);
return;
}
teams.update((old) => {
old.push(Object.assign(team, { id: data.id }));
return old;
});
}
export async function editTeam(team: Team) {
const { error } = await actions.team.editTeam(team);
if (error) {
actionErrorPopup(error);
return;
}
teams.update((old) => {
const index = old.findIndex((a) => a.id == team.id);
old[index] = team;
return old;
});
}

View File

@ -0,0 +1,4 @@
import type { Teams } from './types.ts';
import { writable } from 'svelte/store';
export const teams = writable<Teams>([]);

View File

@ -0,0 +1,6 @@
import { type ActionReturnType, actions } from 'astro:actions';
export type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
export type Team = Teams[0];
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];

View File

@ -0,0 +1,95 @@
<script lang="ts">
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import Input from '@components/input/Input.svelte';
import { onDestroy } from 'svelte';
import type { User } from './types.ts';
import { userCreateOrEditPopupState } from './state.ts';
// html bindings
let modal: HTMLDialogElement;
let modalForm: HTMLFormElement;
// input
let action = $state<'create' | 'edit' | null>(null);
let user = $state({} as User);
let onUpdate = $state((_: User) => {});
// lifecycle
const cancel = userCreateOrEditPopupState.subscribe((value) => {
if (value && 'create' in value) {
action = 'create';
user = {
id: -1,
username: '',
firstname: '',
lastname: '',
birthday: new Date().toISOString().slice(0, 10),
telephone: '',
uuid: ''
};
onUpdate = value?.create.onUpdate;
modal.show();
} else if (value && 'edit' in value) {
action = 'edit';
user = value.edit.user;
onUpdate = value.edit.onUpdate;
modal.show();
}
});
onDestroy(cancel);
// texts
const texts = {
create: {
title: 'Nutzer erstellen',
buttonTitle: 'Erstellen',
confirmPopupTitle: 'Nutzer erstellen?',
confirmPopupMessage: 'Sollen der neue Nutzer erstellt werden?'
},
edit: {
title: 'Nutzer bearbeiten',
buttonTitle: 'Speichern',
confirmPopupTitle: 'Änderunge speichern?',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
},
null: {}
};
// callbacks
function onSaveButtonClick(e: Event) {
e.preventDefault();
$confirmPopupState = {
title: texts[action!].confirmPopupTitle,
message: texts[action!].confirmPopupMessage,
onConfirm: () => {
modalForm.submit();
onUpdate(user);
}
};
}
</script>
<dialog class="modal" bind:this={modal}>
<form method="dialog" class="modal-box" bind:this={modalForm}>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div class="space-y-5">
<h3 class="text-xl font-geist font-bold">{texts[action!].title}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
<Input type="text" bind:value={user.firstname} label="Vorname" />
<Input type="text" bind:value={user.lastname} label="Nachname" />
<Input type="date" bind:value={user.birthday} label="Geburtstag" />
<Input type="tel" bind:value={user.telephone} label="Telefonnummer" />
<Input type="text" bind:value={user.username} label="Spielername" />
<Input type="text" bind:value={user.uuid} label="UUID" />
</div>
<div>
<button class="btn btn-success" onclick={onSaveButtonClick}>{texts[action!].buttonTitle}</button>
<button class="btn btn-error">Abbrechen</button>
</div>
</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,34 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { userCreateOrEditPopupState } from './state.ts';
import { addUser, fetchUsers } from './actions.ts';
import Input from '@components/input/Input.svelte';
let usernameFilter = $state<string | null>(null);
// lifecycle
$effect(() => {
fetchUsers({ username: usernameFilter });
});
// callbacks
async function onNewUserButtonClick() {
$userCreateOrEditPopupState = {
create: {
onUpdate: addUser
}
};
}
</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={() => onNewUserButtonClick()}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer Nutzer</span>
</button>
</div>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
import type { User } from './types.ts';
import { userCreateOrEditPopupState, users } from './state.ts';
import { editUser } from './actions.ts';
import SortableTr from '@components/admin/table/SortableTr.svelte';
import SortableTh from '@components/admin/table/SortableTh.svelte';
// callbacks
async function onUserEditButtonClick(user: User) {
$userCreateOrEditPopupState = {
edit: {
user: user,
onUpdate: editUser
}
};
}
</script>
<div class="h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<SortableTr data={users}>
<SortableTh style="width: 5%">#</SortableTh>
<SortableTh style="width: 15%" key="firstname">Vorname</SortableTh>
<SortableTh style="width: 15%" key="lastname">Nachname</SortableTh>
<SortableTh style="width: 5%" key="birthday">Geburtstag</SortableTh>
<SortableTh style="width: 12%" key="phone">Telefon</SortableTh>
<SortableTh style="width: 20%" key="username">Username</SortableTh>
<SortableTh style="width: 23%">UUID</SortableTh>
<SortableTh style="width: 5%"></SortableTh>
</SortableTr>
</thead>
<tbody>
{#each $users as user, i (user.id)}
<tr class="hover:bg-base-200">
<td>{i + 1}</td>
<td>{user.firstname}</td>
<td>{user.lastname}</td>
<td>{user.birthday}</td>
<td>{user.telephone}</td>
<td>{user.username}</td>
<td>{user.uuid}</td>
<td>
<button class="cursor-pointer" onclick={() => onUserEditButtonClick(user)}>
<Icon icon="heroicons:pencil-square" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<CreateOrEditPopup />

View File

@ -0,0 +1,56 @@
import { actions } from 'astro:actions';
import { users } from './state.ts';
import type { User } from './types.ts';
import { actionErrorPopup } from '@util/action.ts';
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({
username: user.username,
firstname: user.firstname,
lastname: user.lastname,
birthday: user.birthday,
telephone: user.telephone,
uuid: user.uuid
});
if (error) {
actionErrorPopup(error);
return;
}
users.update((old) => {
old.push(Object.assign(user, { id: data.id }));
return old;
});
}
export async function editUser(user: User) {
const { error } = await actions.user.editUser({
id: user.id,
username: user.username,
firstname: user.firstname,
lastname: user.lastname,
birthday: user.birthday,
telephone: user.telephone,
uuid: user.uuid
});
if (error) {
actionErrorPopup(error);
return;
}
users.update((old) => {
const index = old.findIndex((a) => a.id == user.id);
old[index] = user;
return old;
});
}

View File

@ -0,0 +1,6 @@
import type { UserCreateOrEditPopupState, Users } from './types.ts';
import { writable } from 'svelte/store';
export const users = writable<Users>([]);
export const userCreateOrEditPopupState = writable<UserCreateOrEditPopupState>(null);

View File

@ -0,0 +1,9 @@
import { type ActionReturnType, actions } from 'astro:actions';
export type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
export type User = Users[0];
export type UserCreateOrEditPopupState =
| { create: { onUpdate: (user: User) => void } }
| { edit: { user: User; onUpdate: (user: User) => void } }
| null;

View File

@ -0,0 +1,62 @@
<script lang="ts">
import Steve from '@assets/img/steve.png';
import Team from '@components/website/Team.svelte';
import type { GetDeathsRes } from '@db/schema/death.ts';
import { type ActionReturnType, actions } from 'astro:actions';
interface Props {
teams: Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
deaths: GetDeathsRes;
}
const { teams, deaths }: Props = $props();
</script>
<div class="card bg-base-300 shadow-sm w-full md:w-5/7 xl:w-4/7 sm:p-5 md:p-10">
<table class="table table-fixed">
<thead>
<tr>
<th>Team</th>
<th>Spieler 1</th>
<th>Spieler 2</th>
<th>Kills</th>
</tr>
</thead>
<tbody>
{#each teams as team (team.id)}
<tr>
<td>
<Team name={team.name} color={team.color} />
</td>
<td class="max-w-9 overflow-ellipsis">
{#if team.memberOne.id}
<div class="flex items-center gap-x-2">
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
<span
class="text-xs sm:text-md"
class:line-through={deaths.find((d) => d.deadUserId === team.memberOne.id)}
>{team.memberOne.username}</span
>
</div>
{/if}
</td>
<td>
{#if team.memberTwo.id}
<div class="flex items-center gap-x-2">
<img class="w-4 h-4 pixelated" src={Steve.src} alt="head" />
<span
class="text-xs sm:text-md"
class:line-through={deaths.find((d) => d.deadUserId === team.memberTwo.id)}
>{team.memberTwo.username}</span
>
</div>
{/if}
</td>
<td>
<span class="text-xs sm:text-md">0</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>