Files
sendewerk/src/routes/verwaltung/+page.svelte
T
2026-10-26 21:20:55 +01:00

419 lines
15 KiB
Svelte

<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import FeedEditorDialog from '#lib/components/FeedEditorDialog.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import TechnicalAccessDialog from '#lib/components/TechnicalAccessDialog.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
let feedDialog: { showModal: (feed?: (typeof data.feeds)[number] | null) => void };
let technicalAccessDialog: {
showModal: (access?: (typeof data.technicalAccesses)[number] | null) => void;
};
let managementIntent = $derived(form?.intent?.toLowerCase() ?? '');
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !['share', 'technicalaccess'].some( (intent) => managementIntent.includes(intent) )}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<button class="compact-add-button" type="button" onclick={() => feedDialog.showModal()}>
<AppIcon name="plus" /> Feed hinzufügen
</button>
</div>
<p class="catalog-note">
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<div class="catalog-settings-list integration-settings-list">
{#each data.feeds as feed (feed.id)}
<article class:inactive={!feed.isEnabled} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
aria-label={`${feed.name} bearbeiten`}
onclick={() => feedDialog.showModal(feed)}
>
<span class="catalog-entry-icon"><AppIcon name="rss" /></span>
<span class="catalog-entry-copy">
<strong>{feed.name}</strong>
<small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'} · {feed.isEnabled ? 'Automatisch' : 'Pausiert'}</small
>
</span>
<AppIcon name="pen" />
</button>
<div class="catalog-entry-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} />
<button type="submit" aria-label={`${feed.name} jetzt abrufen`} title="Jetzt abrufen"
><AppIcon name="arrow-rotate-right" /></button
>
</form>
<form
method="POST"
action="?/deleteFeed"
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} />
<button
class="danger-text"
type="submit"
aria-label={`${feed.name} löschen`}
title="Feed löschen"><AppIcon name="trash" /></button
>
</form>
</div>
</article>
{/each}
{#if !data.feeds.length}<p class="empty-copy">Noch keine RSS-Feeds eingerichtet.</p>{/if}
</div>
</section>
<FeedEditorDialog bind:this={feedDialog} categories={data.taxonomy.categories} />
<section class="quiet-panel integration-catalog">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<button
class="compact-add-button"
type="button"
onclick={() => technicalAccessDialog.showModal()}
>
<AppIcon name="plus" /> Zugang hinzufügen
</button>
</div>
<p class="catalog-note">
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein. <a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI öffnen</a>
</p>
<div class="catalog-settings-list integration-settings-list">
{#each data.technicalAccesses as access (access.id)}
<article class:inactive={Boolean(access.revokedAtUtc)} class="catalog-setting-row">
<button
class="catalog-entry-main"
type="button"
disabled={Boolean(access.revokedAtUtc)}
aria-label={`${access.name} bearbeiten`}
onclick={() => technicalAccessDialog.showModal(access)}
>
<span class="catalog-entry-icon"><AppIcon name="key" /></span>
<span class="catalog-entry-copy">
<strong>{access.name}</strong>
<small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
>
</span>
{#if !access.revokedAtUtc}<AppIcon name="pen" />{/if}
</button>
{#if !access.revokedAtUtc}<div class="catalog-entry-actions">
<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} />
<button
class="danger-text"
type="submit"
aria-label={`${access.name} widerrufen`}
title="Zugang widerrufen"><AppIcon name="ban" /></button
>
</form>
</div>{/if}
</article>
{/each}
{#if !data.technicalAccesses.length}<p class="empty-copy">
Noch keine technischen Zugänge angelegt.
</p>{/if}
</div>
</section>
<TechnicalAccessDialog bind:this={technicalAccessDialog} {form} />
<section class="quiet-panel account-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Konten & Rollen</p>
<h2>Redaktionszugänge</h2>
</div>
<span class="mail-mode"
>Mail: {data.mailTransport === 'local' ? 'lokale Outbox' : 'SMTP'}</span
>
</div>
<div class="account-list">
{#each data.users as user (user.id)}
<article class="account-row">
<div><strong>{user.displayName}</strong><small>{user.email}</small></div>
<span class:locked={user.lockedAtUtc} class="account-status"
>{user.lockedAtUtc ? 'Gesperrt' : 'Aktiv'}</span
>
<form method="POST" action="?/role" class="inline-account-form">
<input type="hidden" name="userId" value={user.id} />
<label class="sr-only" for={`role-${user.id}`}>Rolle für {user.displayName}</label>
<select id={`role-${user.id}`} name="role">
<option value="barkeeper" selected={user.role === 'barkeeper'}>Barkeeper</option>
<option value="thekencrew" selected={user.role === 'thekencrew'}>Thekencrew</option>
</select>
<button class="text-button" type="submit">Rolle speichern</button>
</form>
<form method="POST" action={user.lockedAtUtc ? '?/unlock' : '?/lock'}>
<input type="hidden" name="userId" value={user.id} />
<button
class="secondary-button"
type="submit"
disabled={user.id === data.user?.id}
title={user.id === data.user?.id
? 'Das eigene Konto kann nicht gesperrt werden.'
: undefined}
>{user.lockedAtUtc
? 'Entsperren'
: user.id === data.user?.id
? 'Eigenes Konto'
: 'Sperren'}</button
>
</form>
</article>
{/each}
</div>
</section>
<div class="management-grid">
<section class="quiet-panel invite-panel">
<p class="eyebrow">Einladung</p>
<h2>Neues Konto einladen</h2>
<form class="compact-form" method="POST" action="?/invite">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.intent === 'invite' ? (form.email ?? '') : ''}
/></label
>
<label
>Rolle<select name="role"
><option value="thekencrew">Thekencrew</option><option value="barkeeper"
>Barkeeper</option
></select
></label
>
<button class="primary-button" type="submit">Einladung erstellen</button>
</form>
</section>
<section class="quiet-panel invite-panel">
<p class="eyebrow">Offene Einladungen</p>
<h2>Noch nicht angenommen</h2>
{#if data.invitations.length}
<div class="invitation-list">
{#each data.invitations as invitation (invitation.id)}
<div>
<span
><strong>{invitation.email}</strong><small
>{invitation.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small
></span
>
<form method="POST" action="?/revokeInvitation">
<input type="hidden" name="invitationId" value={invitation.id} /><button
class="text-button"
type="submit">Widerrufen</button
>
</form>
</div>
{/each}
</div>
{:else}<p class="quiet-copy">Keine offenen Einladungen.</p>{/if}
</section>
</div>
<h2 class="section-heading">Werkzeuge & Freigaben</h2>
<ShareManagement shows={data.shows} shares={data.shares} {form} catalogPanel />
<section class="quiet-panel tool-management">
<div class="panel-heading">
<div>
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
<h2>Werkzeugkatalog</h2>
</div>
<button class="compact-add-button" type="button" onclick={() => toolDialog.showModal()}>
<AppIcon name="plus" /> Werkzeug hinzufügen
</button>
</div>
<p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)}
<button
type="button"
class:inactive={!tool.isActive}
aria-label={`${tool.name} bearbeiten`}
onclick={() => toolDialog.showModal(tool)}
>
<span class="catalog-entry-icon"><AppIcon name={tool.iconName || 'wrench'} /></span>
<span class="catalog-entry-copy">
<strong>{tool.name}</strong>
<small
>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}{#if tool.formatIds.length}
· {data.taxonomy.formats
.filter((format) => tool.formatIds.includes(format.id))
.map((format) => format.name)
.join(', ')}{/if}</small
>
</span>
<AppIcon name="pen" />
</button>
{/each}
</div>
</section>
<ToolEditorDialog bind:this={toolDialog} formats={data.taxonomy.formats} />
<h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2>
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="category" /><input
type="hidden"
name="taxonomyId"
value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="category" /><label
><span class="sr-only">Neue Abschnittskategorie</span><input
name="name"
required
maxlength="80"
placeholder="Neue Kategorie"
/></label
><label class="color-field"
><span class="sr-only">Farbe</span><input
name="color"
type="color"
value="#67DDE8"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2>
<div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span
>{format.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="format" /><input
type="hidden"
name="taxonomyId"
value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span
>{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="format" /><label
><span class="sr-only">Neues Format</span><input
name="name"
required
maxlength="80"
placeholder="Neues Format"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
<section class="quiet-panel taxonomy-panel topic-management-panel">
<p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2>
<div class="topic-settings-list">
{#each data.taxonomy.topics as topic (topic.id)}
<details>
<summary
><AppIcon name={topic.iconName || 'circle'} /><strong>{topic.name}</strong><AppIcon
name="pen"
/></summary
>
<form class="topic-edit-form" method="POST" action="?/updateTaxonomy">
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<label>Name<input name="name" required maxlength="80" value={topic.name} /></label>
<IconPicker name={topic.iconName} />
<button class="secondary-button" type="submit">Änderungen speichern</button>
</form>
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<button class="text-button danger-text" type="submit">Thema löschen</button>
</form>
</details>
{/each}
</div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label
><span class="sr-only">Neues Thema</span><input
name="name"
required
maxlength="80"
placeholder="Neues Thema"
/></label
><IconPicker name="" /><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section>
</div>
</div>
<style>
.integration-catalog {
margin-block: 1.5rem;
padding: clamp(0.85rem, 2vw, 1.2rem);
}
.integration-catalog .catalog-note a {
white-space: nowrap;
}
</style>