refactor admin crud popups
All checks were successful
deploy / build-and-deploy (push) Successful in 23s
All checks were successful
deploy / build-and-deploy (push) Successful in 23s
This commit is contained in:
parent
8b18623232
commit
e47268111a
@ -1,63 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Badges from './Badges.svelte';
|
import BitBadge from '@components/input/BitBadge.svelte';
|
||||||
import { Permissions } from '@util/permissions.ts';
|
import { Permissions } from '@util/permissions.ts';
|
||||||
import type { Admin } from './types.ts';
|
import { admins, editAdmin } from '@app/admin/admins/admins.ts';
|
||||||
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import { admins } from './state.ts';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import { editAdmin } from './actions.ts';
|
|
||||||
|
|
||||||
// consts
|
// state
|
||||||
const availablePermissionBadges = {
|
let editPopupAdmin = $state(null);
|
||||||
[Permissions.Admin.value]: 'Admin',
|
let editPopupOpen = $derived(!!editPopupAdmin);
|
||||||
[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>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen overflow-x-auto">
|
{#snippet permissionsBadge(permissions: number)}
|
||||||
<table class="table table-pin-rows">
|
<BitBadge available={Permissions.asOptions()} value={permissions} readonly />
|
||||||
<thead>
|
{/snippet}
|
||||||
<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)}
|
|
||||||
<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}
|
<DataTable
|
||||||
<CreateOrEditPopup
|
data={admins}
|
||||||
popupTitle="Admin bearbeiten"
|
count={true}
|
||||||
submitButtonTitle="Admin bearbeiten"
|
keys={[
|
||||||
confirmPopupTitle="Admin bearbeiten"
|
{ key: 'username', label: 'Username', width: 30 },
|
||||||
confirmPopupMessage="Bist du sicher, dass du den Admin bearbeiten möchtest?"
|
{ key: 'permissions', label: 'Berechtigungen', width: 60, transform: permissionsBadge }
|
||||||
admin={editAdminPopupAdmin}
|
]}
|
||||||
open={editAdminPopupAdmin != null}
|
onEdit={(admin) => (editPopupAdmin = admin)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
onSubmit={editAdmin}
|
||||||
/>
|
bind:open={editPopupOpen}
|
||||||
{/key}
|
/>
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
<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);
|
|
||||||
onUpdate?.(reactiveSet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
|
@ -1,115 +0,0 @@
|
|||||||
<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 && (admin || 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={!admin} />
|
|
||||||
</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>
|
|
@ -1,34 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { addAdmin, fetchAdmins } from './actions.ts';
|
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { onMount } from 'svelte';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import CreateOrEditPopup from '@app/admin/admins/CreateOrEditPopup.svelte';
|
import { addAdmin, fetchAdmins } from '@app/admin/admins/admins.ts';
|
||||||
|
import { Permissions } from '@util/permissions.ts';
|
||||||
|
|
||||||
|
// state
|
||||||
|
let createPopupOpen = $state(false);
|
||||||
|
|
||||||
// lifecycle
|
// lifecycle
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
fetchAdmins();
|
fetchAdmins();
|
||||||
});
|
});
|
||||||
|
|
||||||
// states
|
|
||||||
let newTeamPopupOpen = $state(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||||
<Icon icon="heroicons:plus-16-solid" />
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
<span>Neuer Admin</span>
|
<span>Neuer Admin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#key newTeamPopupOpen}
|
<CrudPopup
|
||||||
<CreateOrEditPopup
|
texts={{
|
||||||
popupTitle="Admin erstellen"
|
title: 'Admin erstellen',
|
||||||
submitButtonTitle="Admin erstellen"
|
submitButtonTitle: 'Erstellen',
|
||||||
confirmPopupTitle="Admin erstellen"
|
confirmPopupTitle: 'Admin erstellen?',
|
||||||
confirmPopupMessage="Bist du sicher, dass du den Admin erstellen möchtest?"
|
confirmPopupMessage: 'Soll der Admin erstellt werden?'
|
||||||
admin={null}
|
}}
|
||||||
open={newTeamPopupOpen}
|
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}
|
onSubmit={addAdmin}
|
||||||
onClose={() => (newTeamPopupOpen = false)}
|
bind:open={createPopupOpen}
|
||||||
/>
|
/>
|
||||||
{/key}
|
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
import type { Admin } from './types.ts';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
import { actions } from 'astro:actions';
|
import { writable } from 'svelte/store';
|
||||||
import { admins } from './state.ts';
|
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
import { actionErrorPopup } from '@util/action.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() {
|
export async function fetchAdmins() {
|
||||||
const { data, error } = await actions.admin.admins();
|
const { data, error } = await actions.admin.admins();
|
||||||
if (error) {
|
if (error) {
|
@ -1,4 +0,0 @@
|
|||||||
import type { Admin } from './types.ts';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const admins = writable<Admin[]>([]);
|
|
@ -1,4 +0,0 @@
|
|||||||
import type { ActionReturnType, actions } from 'astro:actions';
|
|
||||||
|
|
||||||
export type Admins = Exclude<ActionReturnType<typeof actions.admin.admins>['data'], undefined>['admins'];
|
|
||||||
export type Admin = Admins[0];
|
|
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Feedback } from './types.ts';
|
|
||||||
import Input from '@components/input/Input.svelte';
|
import Input from '@components/input/Input.svelte';
|
||||||
import Textarea from '@components/input/Textarea.svelte';
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
|
import type { Feedback } from '@app/admin/feedback/feedback.ts';
|
||||||
|
|
||||||
// types
|
// types
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -20,7 +20,7 @@
|
|||||||
<div class="w-96">
|
<div class="w-96">
|
||||||
<Input value={feedback?.event} label="Event" readonly />
|
<Input value={feedback?.event} label="Event" readonly />
|
||||||
<Input value={feedback?.title} label="Titel" readonly />
|
<Input value={feedback?.title} label="Titel" readonly />
|
||||||
<Input value={feedback?.user?.username} label="Nutzer" readonly />
|
<Input value={feedback?.username} label="Nutzer" readonly />
|
||||||
</div>
|
</div>
|
||||||
<div class="divider divider-horizontal"></div>
|
<div class="divider divider-horizontal"></div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BottomBar from './BottomBar.svelte';
|
import BottomBar from './BottomBar.svelte';
|
||||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
import { feedbacks, fetchFeedbacks, type Feedback } from '@app/admin/feedback/feedback.ts';
|
||||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
|
||||||
import { feedbacks } from './state.ts';
|
|
||||||
import { fetchFeedbacks } from './actions.ts';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Feedback } from './types.ts';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
|
|
||||||
|
// consts
|
||||||
// consts
|
// consts
|
||||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@ -25,29 +23,20 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-[70vh] max-h-screen overflow-x-auto">
|
{#snippet date(value: string)}
|
||||||
<table class="table table-pin-rows">
|
{dateFormat.format(new Date(value))}
|
||||||
<thead>
|
{/snippet}
|
||||||
<SortableTr data={feedbacks}>
|
|
||||||
<SortableTh style="width: 5%">#</SortableTh>
|
<DataTable
|
||||||
<SortableTh style="width: 10%">Event</SortableTh>
|
data={feedbacks}
|
||||||
<SortableTh style="width: 20%" key="user.username">Nutzer</SortableTh>
|
count={true}
|
||||||
<SortableTh style="width: 20%" key="lastChanged">Datum</SortableTh>
|
keys={[
|
||||||
<SortableTh style="width: 45%">Inhalt</SortableTh>
|
{ key: 'event', label: 'Event', width: 10, sortable: true },
|
||||||
</SortableTr>
|
{ key: 'username', label: 'Nutzer', width: 10, sortable: true },
|
||||||
</thead>
|
{ key: 'lastChanged', label: 'Datum', width: 10, sortable: true, transform: date },
|
||||||
<tbody>
|
{ key: 'content', label: 'Inhalt', width: 10 }
|
||||||
{#each $feedbacks as feedback, i (feedback.id)}
|
]}
|
||||||
<tr class="hover:bg-base-200" onclick={() => (activeFeedback = feedback)}>
|
onClick={(feedback) => (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} />
|
<BottomBar feedback={activeFeedback} />
|
||||||
|
@ -1,7 +1,15 @@
|
|||||||
import { actions } from 'astro:actions';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
import { feedbacks } from './state.ts';
|
import { writable } from 'svelte/store';
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
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) {
|
export async function fetchFeedbacks(reporter?: string | null, reported?: string | null) {
|
||||||
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
|
const { data, error } = await actions.feedback.feedbacks({ reporter: reporter, reported: reported });
|
||||||
if (error) {
|
if (error) {
|
@ -1,4 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { Feedbacks } from './types.ts';
|
|
||||||
|
|
||||||
export const feedbacks = writable<Feedbacks>([]);
|
|
@ -1,4 +0,0 @@
|
|||||||
import type { ActionReturnType, actions } from 'astro:actions';
|
|
||||||
|
|
||||||
export type Feedbacks = Exclude<ActionReturnType<typeof actions.feedback.feedbacks>['data'], undefined>['feedbacks'];
|
|
||||||
export type Feedback = Feedbacks[0];
|
|
@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Report, ReportStatus, StrikeReasons } from './types.ts';
|
import type { Report, ReportStatus, StrikeReasons } from './reports.ts';
|
||||||
import Input from '@components/input/Input.svelte';
|
import Input from '@components/input/Input.svelte';
|
||||||
import Textarea from '@components/input/Textarea.svelte';
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
import Select from '@components/input/Select.svelte';
|
import Select from '@components/input/Select.svelte';
|
||||||
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||||
import { editReportStatus, getReportStatus } from '@app/admin/reports/actions.ts';
|
import { editReportStatus, getReportStatus } from '@app/admin/reports/reports.ts';
|
||||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
@ -1,11 +1,8 @@
|
|||||||
<script lang="ts">
|
<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 BottomBar from '@app/admin/reports/BottomBar.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getStrikeReasons } from '@app/admin/reports/actions.ts';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
|
import { type StrikeReasons, getStrikeReasons, reports } from '@app/admin/reports/reports.ts';
|
||||||
|
|
||||||
// states
|
// states
|
||||||
let strikeReasons = $state<StrikeReasons>([]);
|
let strikeReasons = $state<StrikeReasons>([]);
|
||||||
@ -17,33 +14,18 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen overflow-x-auto">
|
<DataTable
|
||||||
<table class="table table-pin-rows">
|
data={reports}
|
||||||
<thead>
|
count={true}
|
||||||
<SortableTr data={reports}>
|
keys={[
|
||||||
<SortableTh style="width: 5%">#</SortableTh>
|
{ key: 'reason', label: 'Grund' },
|
||||||
<SortableTh>Grund</SortableTh>
|
{ key: 'reporter.name', label: 'Report Team' },
|
||||||
<SortableTh>Report Team</SortableTh>
|
{ key: 'reported.name', label: 'Reportetes Team' },
|
||||||
<SortableTh>Reportetes Team</SortableTh>
|
{ key: 'createdAt', label: 'Datum' },
|
||||||
<SortableTh>Datum</SortableTh>
|
{ key: 'report.status?.status', label: 'Bearbeitungsstatus' }
|
||||||
<SortableTh>Bearbeitungsstatus</SortableTh>
|
]}
|
||||||
<SortableTh style="width: 5%"></SortableTh>
|
onClick={(report) => (activeReport = report)}
|
||||||
</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}
|
{#key activeReport}
|
||||||
<BottomBar {strikeReasons} report={activeReport} />
|
<BottomBar {strikeReasons} report={activeReport} />
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import Input from '@components/input/Input.svelte';
|
import Input from '@components/input/Input.svelte';
|
||||||
import { addReport, fetchReports } from '@app/admin/reports/actions.ts';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import CreatePopup from '@app/admin/reports/CreatePopup.svelte';
|
import { addReport, fetchReports } from '@app/admin/reports/reports.ts';
|
||||||
|
|
||||||
// states
|
// states
|
||||||
let reporterUsernameFilter = $state<string | null>(null);
|
let reporterUsernameFilter = $state<string | null>(null);
|
||||||
let reportedUsernameFilter = $state<string | null>(null);
|
let reportedUsernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
let newReportPopupOpen = $state(false);
|
let createPopupOpen = $state(false);
|
||||||
|
|
||||||
// lifecycle
|
// lifecycle
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -23,12 +23,48 @@
|
|||||||
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
|
<Input bind:value={reportedUsernameFilter} label="Reporteter Spieler" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="divider my-1"></div>
|
<div class="divider my-1"></div>
|
||||||
<button class="btn btn-soft w-full" onclick={() => (newReportPopupOpen = true)}>
|
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||||
<Icon icon="heroicons:plus-16-solid" />
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
<span>Neuer Report</span>
|
<span>Neuer Report</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#key newReportPopupOpen}
|
<CrudPopup
|
||||||
<CreatePopup open={newReportPopupOpen} onSubmit={addReport} onClose={() => (newReportPopupOpen = false)} />
|
texts={{
|
||||||
{/key}
|
title: 'Report erstellen',
|
||||||
|
submitButtonTitle: 'Erstellen',
|
||||||
|
confirmPopupTitle: 'Report erstellen?',
|
||||||
|
confirmPopupMessage: 'Soll der Report erstellt werden?'
|
||||||
|
}}
|
||||||
|
target={null}
|
||||||
|
keys={[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'reporter',
|
||||||
|
type: 'team-search',
|
||||||
|
label: 'Report Team',
|
||||||
|
default: { id: null, name: null },
|
||||||
|
options: { required: true, mustMatch: true, validate: (team) => team?.id != null }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reported',
|
||||||
|
type: 'team-search',
|
||||||
|
label: 'Reportetes Team',
|
||||||
|
default: { id: null, name: null },
|
||||||
|
options: { mustMatch: true, validate: (team) => team?.id != null }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[{ key: 'reason', type: 'text', label: 'Grund', options: { required: true, dynamicWidth: true } }],
|
||||||
|
[{ key: 'body', type: 'textarea', label: 'Inhalt', default: null, options: { rows: 5, dynamicWidth: true } }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Report kann bearbeitet werden',
|
||||||
|
options: { convert: (v) => (v ? new Date().toISOString() : null) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]}
|
||||||
|
onSubmit={addReport}
|
||||||
|
bind:open={createPopupOpen}
|
||||||
|
/>
|
||||||
|
@ -1,8 +1,25 @@
|
|||||||
import { actions } from 'astro:actions';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
import { reports } from './state.ts';
|
import { writable } from 'svelte/store';
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
import type { Report, ReportStatus } from './types.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
|
||||||
|
>;
|
||||||
|
|
||||||
|
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) {
|
export async function fetchReports(reporterUsername: string | null, reportedUsername: string | null) {
|
||||||
const { data, error } = await actions.report.reports({ reporter: reporterUsername, reported: reportedUsername });
|
const { data, error } = await actions.report.reports({ reporter: reporterUsername, reported: reportedUsername });
|
||||||
if (error) {
|
if (error) {
|
@ -1,4 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { Reports } from './types.ts';
|
|
||||||
|
|
||||||
export const reports = writable<Reports>([]);
|
|
@ -1,14 +0,0 @@
|
|||||||
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'];
|
|
@ -1,117 +0,0 @@
|
|||||||
<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 ?? '#000000');
|
|
||||||
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} required />
|
|
||||||
<Input type="text" label="Name" bind:value={name} required />
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<UserSearch
|
|
||||||
label="Spieler 1"
|
|
||||||
onSubmit={(user) => {
|
|
||||||
if (user) memberOne = user;
|
|
||||||
}}
|
|
||||||
bind:value={memberOne.username}
|
|
||||||
required
|
|
||||||
mustMatch
|
|
||||||
/>
|
|
||||||
<UserSearch
|
|
||||||
label="Spieler 2"
|
|
||||||
onSubmit={(user) => {
|
|
||||||
if (user) memberTwo = user;
|
|
||||||
}}
|
|
||||||
bind:value={memberTwo.username}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<Input type="datetime-local" 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>
|
|
@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Input from '@components/input/Input.svelte';
|
import Input from '@components/input/Input.svelte';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { addTeam, fetchTeams } from './actions.ts';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import CreateOrEditPopup from '@app/admin/teams/CreateOrEditPopup.svelte';
|
import { addTeam, fetchTeams } from '@app/admin/teams/teams.ts';
|
||||||
|
|
||||||
// states
|
// states
|
||||||
let teamNameFilter = $state<string | null>(null);
|
let teamNameFilter = $state<string | null>(null);
|
||||||
let memberUsernameFilter = $state<string | null>(null);
|
let memberUsernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
let newTeamPopupOpen = $state(false);
|
let createPopupOpen = $state(false);
|
||||||
|
|
||||||
// lifecycle
|
// lifecycle
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -23,21 +23,51 @@
|
|||||||
<Input bind:value={memberUsernameFilter} label="Spieler Username" />
|
<Input bind:value={memberUsernameFilter} label="Spieler Username" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="divider my-1"></div>
|
<div class="divider my-1"></div>
|
||||||
<button class="btn btn-soft w-full" onclick={() => (newTeamPopupOpen = true)}>
|
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||||
<Icon icon="heroicons:plus-16-solid" />
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
<span>Neues Team</span>
|
<span>Neues Team</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#key newTeamPopupOpen}
|
<CrudPopup
|
||||||
<CreateOrEditPopup
|
texts={{
|
||||||
popupTitle="Neues Team"
|
title: 'Team erstellen',
|
||||||
submitButtonTitle="Team erstellen"
|
submitButtonTitle: 'Erstellen',
|
||||||
confirmPopupTitle="Team erstellen"
|
confirmPopupTitle: 'Team erstellen?',
|
||||||
confirmPopupMessage="Bist du sicher, dass du das Team erstellen möchtest?"
|
confirmPopupMessage: 'Sollen das neue Team erstellt werden?'
|
||||||
team={null}
|
}}
|
||||||
open={newTeamPopupOpen}
|
target={null}
|
||||||
|
keys={[
|
||||||
|
[
|
||||||
|
{ key: 'name', type: 'text', label: 'Name', options: { required: true } },
|
||||||
|
{ key: 'color', type: 'color', label: 'Farbe', options: { required: true } }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'memberOne',
|
||||||
|
type: 'user-search',
|
||||||
|
label: 'Spieler 1',
|
||||||
|
default: { id: null, username: '' },
|
||||||
|
options: { required: true, validate: (user) => !!user.username }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memberTwo',
|
||||||
|
type: 'user-search',
|
||||||
|
label: 'Spieler 2',
|
||||||
|
default: { id: null, username: '' },
|
||||||
|
options: { required: true, validate: (user) => !!user.username }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'lastJoined',
|
||||||
|
type: 'datetime-local',
|
||||||
|
label: 'Zuletzt gejoined',
|
||||||
|
default: null,
|
||||||
|
options: { convert: (date) => (date ? date : null) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]}
|
||||||
onSubmit={addTeam}
|
onSubmit={addTeam}
|
||||||
onClose={() => (newTeamPopupOpen = false)}
|
bind:open={createPopupOpen}
|
||||||
/>
|
/>
|
||||||
{/key}
|
|
||||||
|
@ -1,65 +1,72 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { teams } from './state.ts';
|
import { addTeam, teams } from '@app/admin/teams/teams.ts';
|
||||||
import type { Team } from './types.ts';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
import { editTeam } from './actions.ts';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
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
|
// state
|
||||||
let editTeamPopupTeam = $state<Team | null>(null);
|
let editPopupTeam = $state(null);
|
||||||
|
let editPopupOpen = $derived(!!editPopupTeam);
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
$effect(() => {
|
||||||
|
if (!editPopupOpen) editPopupTeam = null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen overflow-x-auto">
|
{#snippet color(value: string)}
|
||||||
<table class="table table-pin-rows">
|
<div class="rounded-sm w-3 h-3" style="background-color: {value}"></div>
|
||||||
<thead>
|
{/snippet}
|
||||||
<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}
|
<DataTable
|
||||||
<CreateOrEditPopup
|
data={teams}
|
||||||
popupTitle="Team bearbeiten"
|
count={true}
|
||||||
submitButtonTitle="Team bearbeiten"
|
keys={[
|
||||||
confirmPopupTitle="Team bearbeiten"
|
{ key: 'color', label: 'Farbe', width: 5, transform: color },
|
||||||
confirmPopupMessage="Bist du sicher, dass du das Team bearbeiten möchtest?"
|
{ key: 'name', label: 'Name', width: 25 },
|
||||||
team={editTeamPopupTeam}
|
{ key: 'memberOne.username', label: 'Spieler 1', width: 30 },
|
||||||
open={editTeamPopupTeam != null}
|
{ key: 'memberTwo.username', label: 'Spieler 2', width: 30 }
|
||||||
onSubmit={editTeam}
|
]}
|
||||||
/>
|
onEdit={(team) => (editPopupTeam = team)}
|
||||||
{/key}
|
/>
|
||||||
|
|
||||||
|
<CrudPopup
|
||||||
|
texts={{
|
||||||
|
title: 'Team bearbeiten',
|
||||||
|
submitButtonTitle: 'Speichern',
|
||||||
|
confirmPopupTitle: 'Änderungen speichern?',
|
||||||
|
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
||||||
|
}}
|
||||||
|
target={editPopupTeam}
|
||||||
|
keys={[
|
||||||
|
[
|
||||||
|
{ key: 'name', type: 'text', label: 'Name', options: { required: true } },
|
||||||
|
{ key: 'color', type: 'color', label: 'Farbe', options: { required: true } }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'memberOne',
|
||||||
|
type: 'user-search',
|
||||||
|
label: 'Spieler 1',
|
||||||
|
options: { required: true, validate: (user) => !!user.username }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memberTwo',
|
||||||
|
type: 'user-search',
|
||||||
|
label: 'Spieler 2',
|
||||||
|
default: { id: null, username: null },
|
||||||
|
options: { required: true, validate: (user) => !!user.username }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'lastJoined',
|
||||||
|
type: 'datetime-local',
|
||||||
|
label: 'Zuletzt gejoined',
|
||||||
|
default: { id: null, username: null },
|
||||||
|
options: { convert: (date) => (date ? date : null) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]}
|
||||||
|
onSubmit={addTeam}
|
||||||
|
bind:open={editPopupOpen}
|
||||||
|
/>
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
import type { Teams } from './types.ts';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const teams = writable<Teams>([]);
|
|
@ -1,8 +1,17 @@
|
|||||||
import { actions } from 'astro:actions';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
import { teams } from './state.ts';
|
import { writable } from 'svelte/store';
|
||||||
import type { Team } from './types.ts';
|
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
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'];
|
||||||
|
|
||||||
|
// state
|
||||||
|
export const teams = writable<Teams>([]);
|
||||||
|
|
||||||
|
// actions
|
||||||
export async function fetchTeams(name: string | null, username: string | null) {
|
export async function fetchTeams(name: string | null, username: string | null) {
|
||||||
const { data, error } = await actions.team.teams({ name: name, username: username });
|
const { data, error } = await actions.team.teams({ name: name, username: username });
|
||||||
if (error) {
|
if (error) {
|
@ -1,6 +0,0 @@
|
|||||||
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'];
|
|
@ -1,95 +0,0 @@
|
|||||||
<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>
|
|
@ -1,24 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { userCreateOrEditPopupState } from './state.ts';
|
|
||||||
import { addUser, fetchUsers } from './actions.ts';
|
|
||||||
import Input from '@components/input/Input.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 usernameFilter = $state<string | null>(null);
|
||||||
|
|
||||||
|
let createPopupOpen = $state(false);
|
||||||
|
|
||||||
// lifecycle
|
// lifecycle
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchUsers({ username: usernameFilter });
|
fetchUsers({ username: usernameFilter });
|
||||||
});
|
});
|
||||||
|
|
||||||
// callbacks
|
|
||||||
async function onNewUserButtonClick() {
|
|
||||||
$userCreateOrEditPopupState = {
|
|
||||||
create: {
|
|
||||||
onUpdate: addUser
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -27,8 +21,34 @@
|
|||||||
<Input bind:value={usernameFilter} label="Username" />
|
<Input bind:value={usernameFilter} label="Username" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="divider my-1"></div>
|
<div class="divider my-1"></div>
|
||||||
<button class="btn btn-soft w-full" onclick={() => onNewUserButtonClick()}>
|
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||||
<Icon icon="heroicons:plus-16-solid" />
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
<span>Neuer Nutzer</span>
|
<span>Neuer Nutzer</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
|
@ -1,56 +1,54 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
import { editUser, users } from '@app/admin/users/users.ts';
|
||||||
import type { User } from './types.ts';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
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
|
// state
|
||||||
async function onUserEditButtonClick(user: User) {
|
let editPopupUser = $state(null);
|
||||||
$userCreateOrEditPopupState = {
|
let editPopupOpen = $derived(!!editPopupUser);
|
||||||
edit: {
|
|
||||||
user: user,
|
// lifecycle
|
||||||
onUpdate: editUser
|
$effect(() => {
|
||||||
}
|
if (!editPopupOpen) editPopupUser = null;
|
||||||
};
|
});
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen overflow-x-auto">
|
<DataTable
|
||||||
<table class="table table-pin-rows">
|
data={users}
|
||||||
<thead>
|
count={true}
|
||||||
<SortableTr data={users}>
|
keys={[
|
||||||
<SortableTh style="width: 5%">#</SortableTh>
|
{ key: 'firstname', label: 'Vorname', width: 15, sortable: true },
|
||||||
<SortableTh style="width: 15%" key="firstname">Vorname</SortableTh>
|
{ key: 'lastname', label: 'Nachname', width: 15, sortable: true },
|
||||||
<SortableTh style="width: 15%" key="lastname">Nachname</SortableTh>
|
{ key: 'birthday', label: 'Geburtstag', width: 5, sortable: true },
|
||||||
<SortableTh style="width: 5%" key="birthday">Geburtstag</SortableTh>
|
{ key: 'telephone', label: 'Telefon', width: 12, sortable: true },
|
||||||
<SortableTh style="width: 12%" key="phone">Telefon</SortableTh>
|
{ key: 'username', label: 'Username', width: 20, sortable: true },
|
||||||
<SortableTh style="width: 20%" key="username">Username</SortableTh>
|
{ key: 'uuid', label: 'UUID', width: 23 }
|
||||||
<SortableTh style="width: 23%">UUID</SortableTh>
|
]}
|
||||||
<SortableTh style="width: 5%"></SortableTh>
|
onEdit={(user) => (editPopupUser = user)}
|
||||||
</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 />
|
<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}
|
||||||
|
/>
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import type { UserCreateOrEditPopupState, Users } from './types.ts';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const users = writable<Users>([]);
|
|
||||||
|
|
||||||
export const userCreateOrEditPopupState = writable<UserCreateOrEditPopupState>(null);
|
|
@ -1,9 +0,0 @@
|
|||||||
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;
|
|
@ -1,8 +1,15 @@
|
|||||||
import { actions } from 'astro:actions';
|
import { writable } from 'svelte/store';
|
||||||
import { users } from './state.ts';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
import type { User } from './types.ts';
|
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
import { actionErrorPopup } from '@util/action.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 }) {
|
export async function fetchUsers(options?: { username?: string | null }) {
|
||||||
const { data, error } = await actions.user.users({ username: options?.username });
|
const { data, error } = await actions.user.users({ username: options?.username });
|
||||||
if (error) {
|
if (error) {
|
@ -1,101 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Input from '@components/input/Input.svelte';
|
|
||||||
import Textarea from '@components/input/Textarea.svelte';
|
|
||||||
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
|
||||||
import type { BlockedUser } from '@app/admin/usersBlocked/types.ts';
|
|
||||||
import { blockedUserCreateOrEditPopupState } from '@app/admin/usersBlocked/state.ts';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
// html bindings
|
|
||||||
let modal: HTMLDialogElement;
|
|
||||||
let modalForm: HTMLFormElement;
|
|
||||||
|
|
||||||
// states
|
|
||||||
let action = $state<'create' | 'edit' | null>(null);
|
|
||||||
let blockedUser = $state({} as BlockedUser);
|
|
||||||
let onUpdate = $state((_: BlockedUser) => {});
|
|
||||||
|
|
||||||
let submitEnabled = $derived(!!blockedUser.uuid);
|
|
||||||
|
|
||||||
// lifecycle
|
|
||||||
const cancel = blockedUserCreateOrEditPopupState.subscribe((value) => {
|
|
||||||
if (value && 'create' in value) {
|
|
||||||
action = 'create';
|
|
||||||
blockedUser = {
|
|
||||||
id: -1,
|
|
||||||
uuid: '',
|
|
||||||
comment: null
|
|
||||||
};
|
|
||||||
onUpdate = value?.create.onUpdate;
|
|
||||||
modal.show();
|
|
||||||
} else if (value && 'edit' in value) {
|
|
||||||
action = 'edit';
|
|
||||||
blockedUser = value.edit.blockedUser;
|
|
||||||
onUpdate = value.edit.onUpdate;
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(cancel);
|
|
||||||
|
|
||||||
// texts
|
|
||||||
const texts = {
|
|
||||||
create: {
|
|
||||||
title: 'Blockierten Nutzer erstellen',
|
|
||||||
buttonTitle: 'Erstellen',
|
|
||||||
confirmPopupTitle: 'Nutzer blockieren?',
|
|
||||||
confirmPopupMessage:
|
|
||||||
'Bist du sicher, dass der Nutzer blockiert werden soll?\nEin blockierter Nutzer kann sich nicht mehr registrieren.'
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
title: 'Blockierten Nutzer bearbeiten',
|
|
||||||
buttonTitle: 'Speichern',
|
|
||||||
confirmPopupTitle: 'Änderungen speichern?',
|
|
||||||
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
|
|
||||||
},
|
|
||||||
null: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// callbacks
|
|
||||||
async function onSaveButtonClick(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
$confirmPopupState = {
|
|
||||||
title: texts[action!].confirmPopupTitle,
|
|
||||||
message: texts[action!].confirmPopupMessage,
|
|
||||||
onConfirm: () => {
|
|
||||||
modalForm.submit();
|
|
||||||
onUpdate(blockedUser);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCancelButtonClick(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
modalForm.submit();
|
|
||||||
}
|
|
||||||
</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" onclick={onCancelButtonClick}>✕</button>
|
|
||||||
<div class="space-y-5">
|
|
||||||
<h3 class="text-xt font-geist font-bold">{texts[action!].title}</h3>
|
|
||||||
<div>
|
|
||||||
<Input bind:value={blockedUser.uuid} label="UUID" required dynamicWidth />
|
|
||||||
<Textarea label="Kommentar" bind:value={blockedUser.comment} rows={3} dynamicWidth />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
class="btn btn-success"
|
|
||||||
class:disabled={!submitEnabled}
|
|
||||||
disabled={!submitEnabled}
|
|
||||||
onclick={onSaveButtonClick}>{texts[action!].buttonTitle}</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>
|
|
@ -1,26 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { addBlockedUser, fetchBlockedUsers } from '@app/admin/usersBlocked/actions.ts';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import { blockedUserCreateOrEditPopupState } from '@app/admin/usersBlocked/state.ts';
|
import { fetchBlockedUsers, addBlockedUser } from '@app/admin/usersBlocked/usersBlocked.ts';
|
||||||
|
|
||||||
|
// states
|
||||||
|
let createPopupOpen = $state(false);
|
||||||
|
|
||||||
// lifecycle
|
// lifecycle
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchBlockedUsers();
|
fetchBlockedUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// callbacks
|
|
||||||
async function onNewUserButtonClick() {
|
|
||||||
$blockedUserCreateOrEditPopupState = {
|
|
||||||
create: {
|
|
||||||
onUpdate: addBlockedUser
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-soft w-full" onclick={() => onNewUserButtonClick()}>
|
<button class="btn btn-soft w-full" onclick={() => (createPopupOpen = true)}>
|
||||||
<Icon icon="heroicons:plus-16-solid" />
|
<Icon icon="heroicons:plus-16-solid" />
|
||||||
<span>Neuer blockierter Nutzer</span>
|
<span>Neuer blockierter Nutzer</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
|
@ -1,48 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import { blockedUsers, editBlockedUser } from '@app/admin/usersBlocked/usersBlocked.ts';
|
||||||
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
|
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
|
||||||
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
import DataTable from '@components/admin/table/DataTable.svelte';
|
||||||
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
|
||||||
import type { BlockedUser } from '@app/admin/usersBlocked/types.ts';
|
|
||||||
import { blockedUserCreateOrEditPopupState, blockedUsers } from '@app/admin/usersBlocked/state.ts';
|
|
||||||
import { editBlockedUser } from '@app/admin/usersBlocked/actions.ts';
|
|
||||||
|
|
||||||
// callbacks
|
// state
|
||||||
async function onBlockedUserEditButtonClick(blockedUser: BlockedUser) {
|
let blockedUserEditPopupBlockedUser = $state(null);
|
||||||
$blockedUserCreateOrEditPopupState = {
|
let blockedUserEditPopupOpen = $derived(!!blockedUserEditPopupBlockedUser);
|
||||||
edit: {
|
|
||||||
blockedUser: blockedUser,
|
// lifecycle
|
||||||
onUpdate: editBlockedUser
|
$effect(() => {
|
||||||
}
|
if (!blockedUserEditPopupOpen) blockedUserEditPopupBlockedUser = null;
|
||||||
};
|
});
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen overflow-x-auto">
|
<DataTable
|
||||||
<table class="table table-pin-rows">
|
data={blockedUsers}
|
||||||
<thead>
|
count={true}
|
||||||
<SortableTr data={blockedUsers}>
|
keys={[
|
||||||
<SortableTh style="width: 5%">#</SortableTh>
|
{ key: 'uuid', label: 'UUID', width: 20, sortable: true },
|
||||||
<SortableTh style="width: 20%" key="uuid">UUID</SortableTh>
|
{ key: 'comment', label: 'Kommentar', width: 70 }
|
||||||
<SortableTh style="width: 70%">Kommentar</SortableTh>
|
]}
|
||||||
<SortableTh style="width: 5%"></SortableTh>
|
onEdit={(blockedUser) => (blockedUserEditPopupBlockedUser = blockedUser)}
|
||||||
</SortableTr>
|
/>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each $blockedUsers as blockedUser, i (blockedUser)}
|
|
||||||
<tr class="hover:bg-base-200">
|
|
||||||
<td>{i + 1}</td>
|
|
||||||
<td>{blockedUser.uuid}</td>
|
|
||||||
<td>{blockedUser.comment}</td>
|
|
||||||
<td>
|
|
||||||
<button class="cursor-pointer" onclick={() => onBlockedUserEditButtonClick(blockedUser)}>
|
|
||||||
<Icon icon="heroicons:pencil-square" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CreateOrEditPopup />
|
<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}
|
||||||
|
/>
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { BlockedUserCreateOrEditPopupState, BlockedUsers } from '@app/admin/usersBlocked/types.ts';
|
|
||||||
|
|
||||||
export const blockedUsers = writable<BlockedUsers>([]);
|
|
||||||
|
|
||||||
export const blockedUserCreateOrEditPopupState = writable<BlockedUserCreateOrEditPopupState>(null);
|
|
@ -1,9 +0,0 @@
|
|||||||
import { type ActionReturnType, actions } from 'astro:actions';
|
|
||||||
|
|
||||||
export type BlockedUsers = Exclude<ActionReturnType<typeof actions.user.blocked>['data'], undefined>['blocked'];
|
|
||||||
export type BlockedUser = BlockedUsers[0];
|
|
||||||
|
|
||||||
export type BlockedUserCreateOrEditPopupState =
|
|
||||||
| { create: { onUpdate: (blockedUser: BlockedUser) => void } }
|
|
||||||
| { edit: { blockedUser: BlockedUser; onUpdate: (blockedUser: BlockedUser) => void } }
|
|
||||||
| null;
|
|
@ -1,8 +1,15 @@
|
|||||||
import { actions } from 'astro:actions';
|
import { type ActionReturnType, actions } from 'astro:actions';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
import { actionErrorPopup } from '@util/action.ts';
|
import { actionErrorPopup } from '@util/action.ts';
|
||||||
import { blockedUsers } from '@app/admin/usersBlocked/state.ts';
|
|
||||||
import type { BlockedUser } from '@app/admin/usersBlocked/types.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() {
|
export async function fetchBlockedUsers() {
|
||||||
const { data, error } = await actions.user.blocked();
|
const { data, error } = await actions.user.blocked();
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -27,14 +34,14 @@ export async function addBlockedUser(blockedUser: BlockedUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function editBlockedUser(blockedUser: BlockedUser) {
|
export async function editBlockedUser(blockedUser: BlockedUser) {
|
||||||
const { data, error } = await actions.user.editBlocked(blockedUser);
|
const { error } = await actions.user.editBlocked(blockedUser);
|
||||||
if (error) {
|
if (error) {
|
||||||
actionErrorPopup(error);
|
actionErrorPopup(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
blockedUsers.update((old) => {
|
blockedUsers.update((old) => {
|
||||||
const index = old.findIndex((a) => a.id == user.id);
|
const index = old.findIndex((a) => a.id == blockedUser.id);
|
||||||
old[index] = blockedUser;
|
old[index] = blockedUser;
|
||||||
return old;
|
return old;
|
||||||
});
|
});
|
255
src/components/admin/popup/CrudPopup.svelte
Normal file
255
src/components/admin/popup/CrudPopup.svelte
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '@components/input/Input.svelte';
|
||||||
|
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
|
||||||
|
import Textarea from '@components/input/Textarea.svelte';
|
||||||
|
import Password from '@components/input/Password.svelte';
|
||||||
|
import BitBadge from '@components/input/BitBadge.svelte';
|
||||||
|
import UserSearch from '@components/admin/search/UserSearch.svelte';
|
||||||
|
import Checkbox from '@components/input/Checkbox.svelte';
|
||||||
|
import TeamSearch from '@components/admin/search/TeamSearch.svelte';
|
||||||
|
|
||||||
|
// html bindings
|
||||||
|
let modal: HTMLDialogElement;
|
||||||
|
let modalForm: HTMLFormElement;
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props<T> {
|
||||||
|
texts: {
|
||||||
|
title: string;
|
||||||
|
submitButtonTitle: string;
|
||||||
|
confirmPopupTitle: string;
|
||||||
|
confirmPopupMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
target: T | null;
|
||||||
|
keys: Key<any>[][];
|
||||||
|
|
||||||
|
onSubmit: (target: T) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
type Key<T extends KeyInputType> = {
|
||||||
|
key: string;
|
||||||
|
type: T | null;
|
||||||
|
label: string;
|
||||||
|
default?: any;
|
||||||
|
options?: KeyInputTypeOptions<T>;
|
||||||
|
};
|
||||||
|
type KeyInputType =
|
||||||
|
| 'bit-badge'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'color'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime-local'
|
||||||
|
| 'password'
|
||||||
|
| 'tel'
|
||||||
|
| 'team-search'
|
||||||
|
| 'text'
|
||||||
|
| 'textarea'
|
||||||
|
| 'user-search';
|
||||||
|
type KeyInputTypeOptions<T extends KeyInputType> = {
|
||||||
|
['bit-badge']: {
|
||||||
|
available: Record<number, string>;
|
||||||
|
};
|
||||||
|
['checkbox']: {};
|
||||||
|
['color']: {};
|
||||||
|
['date']: {};
|
||||||
|
['datetime-local']: {};
|
||||||
|
['password']: {};
|
||||||
|
['tel']: {};
|
||||||
|
['team-search']: {};
|
||||||
|
['text']: {};
|
||||||
|
['textarea']: {
|
||||||
|
rows?: boolean;
|
||||||
|
};
|
||||||
|
['user-search']: {
|
||||||
|
mustMatch?: boolean;
|
||||||
|
};
|
||||||
|
}[T] & {
|
||||||
|
convert?: (value: any) => any;
|
||||||
|
validate?: (value: any) => boolean;
|
||||||
|
required?: boolean;
|
||||||
|
dynamicWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// input
|
||||||
|
let { texts, target, keys, onSubmit, onClose, open = $bindable() }: Props<any> = $props();
|
||||||
|
|
||||||
|
onInit();
|
||||||
|
|
||||||
|
// state
|
||||||
|
let submitEnabled = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
onInit();
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (target == null) onInit();
|
||||||
|
updateSubmitEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// function
|
||||||
|
function onInit() {
|
||||||
|
if (target == null) target = {};
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const k of key) {
|
||||||
|
if (k.default !== undefined && target[k.key] === undefined)
|
||||||
|
target[k.key] = k.options?.convert ? k.options.convert(k.default) : k.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubmitEnabled() {
|
||||||
|
submitEnabled = false;
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const k of key) {
|
||||||
|
if (k.options?.required) {
|
||||||
|
if (k.options?.validate) {
|
||||||
|
if (!k.options.validate(target[k.key])) return;
|
||||||
|
} else {
|
||||||
|
if (!target[k.key]) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onBindChange(key: string, value: any, options?: KeyInputTypeOptions<any>) {
|
||||||
|
target[key] = options?.convert ? options.convert(value) : value;
|
||||||
|
|
||||||
|
updateSubmitEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
$confirmPopupState = {
|
||||||
|
title: texts.confirmPopupTitle,
|
||||||
|
message: texts.confirmPopupMessage,
|
||||||
|
onConfirm: () => {
|
||||||
|
modalForm.submit();
|
||||||
|
onSubmit(target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelButtonClick(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
modalForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalClose() {
|
||||||
|
setTimeout(() => {
|
||||||
|
open = false;
|
||||||
|
target = null;
|
||||||
|
modalForm.reset();
|
||||||
|
onClose?.();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
|
||||||
|
<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">{texts.title}</h3>
|
||||||
|
<div class="w-full flex flex-col">
|
||||||
|
{#each keys as key (key)}
|
||||||
|
<div
|
||||||
|
class="grid grid-flow-col gap-4"
|
||||||
|
class:grid-cols-1={key.length === 1}
|
||||||
|
class:grid-cols-2={key.length === 2}
|
||||||
|
>
|
||||||
|
{#each key as k (k)}
|
||||||
|
{#if k.type === 'color' || k.type === 'date' || k.type === 'datetime-local' || k.type === 'tel' || k.type === 'text'}
|
||||||
|
<Input
|
||||||
|
type={k.type}
|
||||||
|
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
dynamicWidth={k.options?.dynamicWidth}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'textarea'}
|
||||||
|
<Textarea
|
||||||
|
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
rows={k.options?.rows}
|
||||||
|
dynamicWidth={k.options?.dynamicWidth}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'checkbox'}
|
||||||
|
<Checkbox
|
||||||
|
bind:checked={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'user-search'}
|
||||||
|
<UserSearch
|
||||||
|
bind:value={
|
||||||
|
() =>
|
||||||
|
target[k.key] ? (target[k.key]['username'] ?? null) : k.default ? k.default['username'] : null,
|
||||||
|
(v) =>
|
||||||
|
k.options.mustMatch
|
||||||
|
? onBindChange(k.key, { id: null, username: null }, k.options)
|
||||||
|
: onBindChange(k.key, { id: null, username: v }, k.options)
|
||||||
|
}
|
||||||
|
onSubmit={(user) => onBindChange(k.key, user, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
mustMatch={k.options?.mustMatch}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'team-search'}
|
||||||
|
<TeamSearch
|
||||||
|
bind:value={
|
||||||
|
() => (target[k.key] ? (target[k.key]['name'] ?? null) : k.default ? k.default['name'] : null),
|
||||||
|
(v) =>
|
||||||
|
k.options.mustMatch
|
||||||
|
? onBindChange(k.key, { id: null, name: null, color: null }, k.options)
|
||||||
|
: onBindChange(k.key, { id: null, name: v, color: null }, k.options)
|
||||||
|
}
|
||||||
|
onSubmit={(team) => onBindChange(k.key, team, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
mustMatch={k.options?.mustMatch}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'password'}
|
||||||
|
<Password
|
||||||
|
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
|
||||||
|
label={k.label}
|
||||||
|
required={k.options?.required}
|
||||||
|
/>
|
||||||
|
{:else if k.type === 'bit-badge'}
|
||||||
|
<BitBadge
|
||||||
|
available={k.options?.available}
|
||||||
|
bind:value={
|
||||||
|
() => (target[k.key] ? (target[k.key]['name'] ?? target[k.key]) : k.default),
|
||||||
|
(v) => onBindChange(k.key, v, k.options)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
class:disabled={!submitEnabled}
|
||||||
|
disabled={!submitEnabled}
|
||||||
|
onclick={onSaveButtonClick}>{texts.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>
|
@ -20,7 +20,7 @@
|
|||||||
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||||
|
|
||||||
// states
|
// states
|
||||||
let inputValue = $state(value);
|
let inputValue = $derived(value);
|
||||||
let suggestions = $state<string[]>([]);
|
let suggestions = $state<string[]>([]);
|
||||||
let matched = $state(false);
|
let matched = $state(false);
|
||||||
|
|
||||||
@ -38,7 +38,7 @@
|
|||||||
if (suggestion != null) {
|
if (suggestion != null) {
|
||||||
inputValue = value = suggestion;
|
inputValue = value = suggestion;
|
||||||
matched = true;
|
matched = true;
|
||||||
onSubmit?.(value);
|
onSubmit?.(suggestion);
|
||||||
} else if (!mustMatch) {
|
} else if (!mustMatch) {
|
||||||
value = inputValue;
|
value = inputValue;
|
||||||
matched = false;
|
matched = false;
|
||||||
@ -52,7 +52,7 @@
|
|||||||
function onSuggestionClick(suggestion: string) {
|
function onSuggestionClick(suggestion: string) {
|
||||||
inputValue = value = suggestion;
|
inputValue = value = suggestion;
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
onSubmit?.(value);
|
onSubmit?.(suggestion);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
73
src/components/admin/table/DataTable.svelte
Normal file
73
src/components/admin/table/DataTable.svelte
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import SortableTr from '@components/admin/table/SortableTr.svelte';
|
||||||
|
import SortableTh from '@components/admin/table/SortableTh.svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||||
|
|
||||||
|
// types
|
||||||
|
interface Props<T> {
|
||||||
|
data: Writable<T[]>;
|
||||||
|
|
||||||
|
count?: boolean;
|
||||||
|
keys: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
transform?: Snippet<[T]>;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
onClick?: (t: T) => void;
|
||||||
|
onEdit?: (t: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// input
|
||||||
|
let { data, count, keys, onClick, onEdit }: Props<any> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen overflow-x-auto">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<SortableTr {data}>
|
||||||
|
{#if count}
|
||||||
|
<SortableTh style="width: 5%">#</SortableTh>
|
||||||
|
{/if}
|
||||||
|
{#each keys as key (key.key)}
|
||||||
|
<SortableTh style={key.width ? `width: ${key.width}%` : undefined} key={key.sortable ? key.key : undefined}
|
||||||
|
>{key.label}</SortableTh
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if onEdit}
|
||||||
|
<SortableTh style="width: 5%"></SortableTh>
|
||||||
|
{/if}
|
||||||
|
</SortableTr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $data as d, i (d)}
|
||||||
|
<tr class="hover:bg-base-200" onclick={() => onClick?.(d)}>
|
||||||
|
{#if count}
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
{/if}
|
||||||
|
{#each keys as key (key.key)}
|
||||||
|
<td>
|
||||||
|
{#if key.transform}
|
||||||
|
{@render key.transform(getObjectEntryByKey(key.key, d))}
|
||||||
|
{:else}
|
||||||
|
{getObjectEntryByKey(key.key, d)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
{#if onEdit}
|
||||||
|
<td>
|
||||||
|
<button class="cursor-pointer" onclick={() => onEdit(d)}>
|
||||||
|
<Icon icon="heroicons:pencil-square" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext, type Snippet } from 'svelte';
|
import { setContext, type Snippet } from 'svelte';
|
||||||
import { type Writable, writable } from 'svelte/store';
|
import { type Writable, writable } from 'svelte/store';
|
||||||
|
import { getObjectEntryByKey } from '@util/objects.ts';
|
||||||
|
|
||||||
// types
|
// types
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -21,8 +22,8 @@
|
|||||||
function onSort(key: string, order: 'asc' | 'desc') {
|
function onSort(key: string, order: 'asc' | 'desc') {
|
||||||
data.update((old) => {
|
data.update((old) => {
|
||||||
old.sort((a, b) => {
|
old.sort((a, b) => {
|
||||||
let entryA = getDataEntryByKey(key, a);
|
let entryA = getObjectEntryByKey(key, a);
|
||||||
let entryB = getDataEntryByKey(key, b);
|
let entryB = getObjectEntryByKey(key, b);
|
||||||
|
|
||||||
if (entryA === undefined || entryB === undefined) return 0;
|
if (entryA === undefined || entryB === undefined) return 0;
|
||||||
|
|
||||||
@ -40,16 +41,6 @@
|
|||||||
return old;
|
return old;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
|
||||||
let entry = data;
|
|
||||||
for (const part of key.split('.')) {
|
|
||||||
if ((entry = entry[part]) === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr {...restProps}>
|
<tr {...restProps}>
|
||||||
|
50
src/components/input/BitBadge.svelte
Normal file
50
src/components/input/BitBadge.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
available: Record<number, string>;
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputs
|
||||||
|
let { available, value = $bindable(), readonly }: Props = $props();
|
||||||
|
|
||||||
|
// idk why, but this is needed to trigger loop reactivity
|
||||||
|
let reactiveValue = $derived(value);
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
function onOptionSelect(e: Event) {
|
||||||
|
const selected = Number((e.target as HTMLSelectElement).value);
|
||||||
|
|
||||||
|
reactiveValue |= selected;
|
||||||
|
|
||||||
|
(e.target as HTMLSelectElement).value = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBadgeRemove(flag: number) {
|
||||||
|
reactiveValue &= ~flag;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if !readonly}
|
||||||
|
<select class="select select-xs w-min" onchange={onOptionSelect}>
|
||||||
|
<option selected hidden>-</option>
|
||||||
|
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||||
|
<option value={flag} hidden={(reactiveValue & Number(flag)) !== 0}>{badge}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flow flex-wrap gap-2">
|
||||||
|
{#each Object.entries(available) as [flag, badge] (flag)}
|
||||||
|
{#if (reactiveValue & Number(flag)) !== 0}
|
||||||
|
<div class="badge badge-outline gap-1">
|
||||||
|
{#if !readonly}
|
||||||
|
<button class="cursor-pointer" type="button" onclick={() => onBadgeRemove(Number(flag))}>✕</button>
|
||||||
|
{/if}
|
||||||
|
<span>{badge}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -12,13 +12,13 @@ export const blockedUser = mysqlTable('blocked_user', {
|
|||||||
|
|
||||||
export type AddBlockedUserReq = {
|
export type AddBlockedUserReq = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
comment: string | null;
|
comment?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditBlockedUserReq = {
|
export type EditBlockedUserReq = {
|
||||||
id: number;
|
id: number;
|
||||||
uuid: string;
|
uuid: string;
|
||||||
comment: string | null;
|
comment?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetBlockedUserByUuidReq = {
|
export type GetBlockedUserByUuidReq = {
|
||||||
|
@ -68,10 +68,7 @@ export async function getFeedbacks(db: Database, values: GetFeedbacksReq) {
|
|||||||
content: feedback.content,
|
content: feedback.content,
|
||||||
urlHash: feedback.urlHash,
|
urlHash: feedback.urlHash,
|
||||||
lastChanged: feedback.lastChanged,
|
lastChanged: feedback.lastChanged,
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username
|
username: user.username
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.from(feedback)
|
.from(feedback)
|
||||||
.leftJoin(user, eq(feedback.userId, user.id));
|
.leftJoin(user, eq(feedback.userId, user.id));
|
||||||
|
9
src/util/objects.ts
Normal file
9
src/util/objects.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function getObjectEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||||
|
let entry = data;
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if ((entry = entry[part]) === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
@ -32,16 +32,15 @@ export class Permissions {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
toNumberArray() {
|
static asOptions() {
|
||||||
const array = [];
|
return {
|
||||||
if (this.admin) array.push(Permissions.Admin.value);
|
[Permissions.Admin.value]: 'Admin',
|
||||||
if (this.users) array.push(Permissions.Users.value);
|
[Permissions.Users.value]: 'Users',
|
||||||
if (this.reports) array.push(Permissions.Reports.value);
|
[Permissions.Reports.value]: 'Reports',
|
||||||
if (this.feedback) array.push(Permissions.Feedback.value);
|
[Permissions.Feedback.value]: 'Feedback',
|
||||||
if (this.settings) array.push(Permissions.Settings.value);
|
[Permissions.Settings.value]: 'Settings',
|
||||||
if (this.tools) array.push(Permissions.Tools.value);
|
[Permissions.Tools.value]: 'Tools'
|
||||||
|
};
|
||||||
return array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get admin() {
|
get admin() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user