535 lines
17 KiB
Svelte
535 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
|
import IconPicker from '#lib/components/IconPicker.svelte';
|
|
import ShareManagement from '#lib/components/ShareManagement.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 };
|
|
|
|
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 && !form.intent?.toLowerCase().includes('share')}<div
|
|
class="form-message error"
|
|
role="alert"
|
|
>
|
|
{form.error}
|
|
</div>{/if}
|
|
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
|
|
class="form-message success"
|
|
role="status"
|
|
>
|
|
{form.success}
|
|
</div>{/if}
|
|
<h2 class="section-heading">Schnittstellen</h2>
|
|
<section class="quiet-panel integration-panel">
|
|
<div class="panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Import</p>
|
|
<h2>RSS-Feeds</h2>
|
|
</div>
|
|
<span>{data.feeds.length} eingerichtet</span>
|
|
</div>
|
|
<p>
|
|
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
|
|
Eingang.
|
|
</p>
|
|
<details class="integration-create">
|
|
<summary class="compact-add-button"><AppIcon name="plus" /> Feed hinzufügen</summary>
|
|
<form class="integration-form" method="POST" action="?/saveFeed">
|
|
<label>Name<input name="name" required maxlength="120" /></label>
|
|
<label
|
|
>Feed-URL<input
|
|
name="url"
|
|
type="url"
|
|
required
|
|
maxlength="2000"
|
|
placeholder="https://example.org/feed.xml"
|
|
/></label
|
|
>
|
|
<label
|
|
>Intervall in Minuten<input
|
|
name="refreshIntervalMinutes"
|
|
type="number"
|
|
min="15"
|
|
max="10080"
|
|
value="60"
|
|
required
|
|
/></label
|
|
>
|
|
<label
|
|
>Standardkategorie<select name="defaultCategoryId"
|
|
><option value="">Keine</option
|
|
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
|
|
>{category.name}</option
|
|
>{/each}</select
|
|
></label
|
|
>
|
|
<label class="check-row"
|
|
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
|
|
>
|
|
<button class="primary-button" type="submit">Feed anlegen</button>
|
|
</form>
|
|
</details>
|
|
<div class="integration-list">
|
|
{#each data.feeds as feed (feed.id)}
|
|
<details>
|
|
<summary
|
|
><span
|
|
><strong>{feed.name}</strong><small
|
|
>{feed.lastError
|
|
? `Fehler: ${feed.lastError}`
|
|
: feed.lastFetchedAtUtc
|
|
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
|
|
: 'Noch nicht abgerufen'}</small
|
|
></span
|
|
><AppIcon name="pen" /></summary
|
|
>
|
|
<form class="integration-form" method="POST" action="?/saveFeed">
|
|
<input type="hidden" name="feedId" value={feed.id} />
|
|
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
|
|
<label
|
|
>Feed-URL<input
|
|
name="url"
|
|
type="url"
|
|
required
|
|
maxlength="2000"
|
|
value={feed.url}
|
|
/></label
|
|
>
|
|
<label
|
|
>Intervall in Minuten<input
|
|
name="refreshIntervalMinutes"
|
|
type="number"
|
|
min="15"
|
|
max="10080"
|
|
value={feed.refreshIntervalMinutes}
|
|
required
|
|
/></label
|
|
>
|
|
<label
|
|
>Standardkategorie<select name="defaultCategoryId"
|
|
><option value="">Keine</option
|
|
>{#each data.taxonomy.categories as category (category.id)}<option
|
|
value={category.id}
|
|
selected={feed.defaultCategoryId === category.id}>{category.name}</option
|
|
>{/each}</select
|
|
></label
|
|
>
|
|
<label class="check-row"
|
|
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
|
|
abrufen</label
|
|
>
|
|
<button class="secondary-button" type="submit">Speichern</button>
|
|
</form>
|
|
<div class="row-actions">
|
|
<form method="POST" action="?/fetchFeed">
|
|
<input type="hidden" name="feedId" value={feed.id} /><button
|
|
class="text-button"
|
|
type="submit">Jetzt abrufen</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="text-button danger-text"
|
|
type="submit">Feed löschen</button
|
|
>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="quiet-panel integration-panel">
|
|
<div class="panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Externe Assistenz</p>
|
|
<h2>Technische Zugänge</h2>
|
|
</div>
|
|
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
|
|
</div>
|
|
<p>
|
|
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
|
|
idempotent sein.
|
|
</p>
|
|
{#if form?.technicalToken}<div class="token-reveal" role="status">
|
|
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
|
|
</div>{/if}
|
|
<details class="integration-create">
|
|
<summary class="compact-add-button"><AppIcon name="plus" /> Zugang hinzufügen</summary>
|
|
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
|
|
<label
|
|
>Name<input
|
|
name="name"
|
|
required
|
|
maxlength="120"
|
|
placeholder="Redaktionsassistenz"
|
|
/></label
|
|
>
|
|
<label
|
|
>Berechtigung<select name="permissions"
|
|
><option value="read">Nur lesen</option><option value="read_write"
|
|
>Lesen und schreiben</option
|
|
></select
|
|
></label
|
|
>
|
|
<button class="primary-button" type="submit">Zugang anlegen</button>
|
|
</form>
|
|
</details>
|
|
<div class="integration-list">
|
|
{#each data.technicalAccesses as access (access.id)}
|
|
<div class="access-row">
|
|
<span
|
|
><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}<form method="POST" action="?/revokeTechnicalAccess">
|
|
<input type="hidden" name="accessId" value={access.id} /><button
|
|
class="secondary-button"
|
|
type="submit">Widerrufen</button
|
|
>
|
|
</form>{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
|
|
<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-panel {
|
|
display: grid;
|
|
gap: 1rem;
|
|
margin-block: 1.5rem;
|
|
}
|
|
.integration-form {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
|
gap: 0.8rem;
|
|
align-items: end;
|
|
}
|
|
.integration-create > summary {
|
|
width: fit-content;
|
|
cursor: pointer;
|
|
list-style: none;
|
|
}
|
|
.integration-create > summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.integration-create[open] > .integration-form {
|
|
margin-top: 0.8rem;
|
|
}
|
|
.integration-form label:not(.check-row) {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
.check-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
min-height: 2.6rem;
|
|
}
|
|
.integration-list {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
.integration-list details,
|
|
.access-row {
|
|
padding: 0.75rem 0.8rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-medium);
|
|
background: var(--panel-soft);
|
|
}
|
|
.integration-list summary,
|
|
.access-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
.integration-list summary > span,
|
|
.access-row > span {
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
}
|
|
.integration-list small {
|
|
color: var(--text-muted);
|
|
}
|
|
.row-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
.token-reveal {
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
padding: 0.8rem;
|
|
border: 2px solid var(--color-accent);
|
|
border-radius: 0.7rem;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.token-reveal code {
|
|
user-select: all;
|
|
}
|
|
</style>
|