Release 0.7.2

This commit is contained in:
hnzio committed 2026-10-26 21:20:55 +01:00
1 parent 64f55a8b1f
commit d5b1c58ba2
23 files changed
+1630 -455

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Feed = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
};
let { categories }: { categories: { id: string; name: string }[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let feed = $state<Feed | null>(null);
let name = $state('');
let url = $state('');
let refreshIntervalMinutes = $state(60);
let defaultCategoryId = $state('');
let isEnabled = $state(true);
export async function showModal(selectedFeed: Feed | null = null) {
feed = selectedFeed;
name = selectedFeed?.name ?? '';
url = selectedFeed?.url ?? '';
refreshIntervalMinutes = selectedFeed?.refreshIntervalMinutes ?? 60;
defaultCategoryId = selectedFeed?.defaultCategoryId ?? '';
isEnabled = selectedFeed?.isEnabled ?? true;
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog feed-editor-dialog"
aria-labelledby="feed-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Quelle</p>
<h2 id="feed-dialog-title">{feed?.name || 'Feed hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Feeddialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form class="management-dialog-form" method="POST" action="?/saveFeed">
{#if feed}<input type="hidden" name="feedId" value={feed.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Feed-URL<input bind:value={url} name="url" type="url" required maxlength="2000" /></label
>
<label
>Intervall in Minuten<input
bind:value={refreshIntervalMinutes}
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
required
/></label
>
<label
>Standardkategorie<select bind:value={defaultCategoryId} name="defaultCategoryId"
><option value="">Keine</option>{#each categories as category (category.id)}<option
value={category.id}>{category.name}</option
>{/each}</select
></label
>
</div>
<div class="management-dialog-selections">
<label class="toggle-row"
><input type="checkbox" name="isEnabled" value="1" bind:checked={isEnabled} /><span
><strong>Automatischer Abruf</strong><small
>Der Feed wird im festgelegten Intervall sicher abgerufen.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{feed ? 'Änderungen speichern' : 'Feed anlegen'}</button
>
</footer>
</form>
</dialog>
@@ -0,0 +1,219 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
};
let {
entries,
categories,
form = null
}: {
entries: FeedEntry[];
categories: { id: string; name: string }[];
form?: { intent?: string; feedError?: string } | null;
} = $props();
let dialog: HTMLDialogElement;
let closeButton: HTMLButtonElement;
let selectedIds = $state<string[]>([]);
let selectedFeedIds = $state<string[]>([]);
let categoryIds = $state<string[]>([]);
let page = $state(1);
const pageSize = 20;
let feeds = $derived(
Array.from(new Map(entries.map((entry) => [entry.feedId, entry.feedName])).entries()).map(
([id, name]) => ({ id, name })
)
);
let filteredEntries = $derived(entries.filter((entry) => selectedFeedIds.includes(entry.feedId)));
let pageCount = $derived(Math.max(1, Math.ceil(filteredEntries.length / pageSize)));
let visibleEntries = $derived(filteredEntries.slice((page - 1) * pageSize, page * pageSize));
let allSourcesSelected = $derived(feeds.length > 0 && selectedFeedIds.length === feeds.length);
onMount(() => {
selectedFeedIds = feeds.map((feed) => feed.id);
if (form?.intent === 'feedImport' && form.feedError) void showModal();
});
export async function showModal() {
if (!selectedFeedIds.length) selectedFeedIds = feeds.map((feed) => feed.id);
if (!dialog.open) dialog.showModal();
await tick();
closeButton.focus();
}
function close() {
dialog.close();
}
function selectAllSources() {
selectedFeedIds = feeds.map((feed) => feed.id);
page = 1;
}
function toggleSource(id: string) {
selectedFeedIds = selectedFeedIds.includes(id)
? selectedFeedIds.filter((feedId) => feedId !== id)
: [...selectedFeedIds, id];
page = 1;
}
function toggleEntry(id: string) {
selectedIds = selectedIds.includes(id)
? selectedIds.filter((entryId) => entryId !== id)
: [...selectedIds, id];
}
function handleEntryKeydown(event: KeyboardEvent, id: string) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleEntry(id);
}
</script>
<dialog
bind:this={dialog}
class="management-dialog rss-selection-dialog"
aria-labelledby="rss-selection-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2 id="rss-selection-title">RSS-Beiträge auswählen</h2>
</div>
</div>
<button
bind:this={closeButton}
class="dialog-close"
type="button"
aria-label="RSS-Auswahl schließen"
onclick={close}><AppIcon name="xmark" /></button
>
</header>
<form class="rss-selection-form" method="POST" action="?/importFeeds">
{#each selectedIds as id (id)}<input type="hidden" name="entryIds" value={id} />{/each}
<div class="rss-selection-content">
{#if form?.intent === 'feedImport' && form.feedError}<div
class="form-message error"
role="alert"
>
{form.feedError}
</div>{/if}
<nav class="rss-source-filters" aria-label="RSS-Quellen filtern">
<button
type="button"
class:active={allSourcesSelected}
aria-pressed={allSourcesSelected}
onclick={selectAllSources}>Alle Quellen</button
>
{#each feeds as feed (feed.id)}
<button
type="button"
class:active={selectedFeedIds.includes(feed.id)}
aria-pressed={selectedFeedIds.includes(feed.id)}
onclick={() => toggleSource(feed.id)}>{feed.name}</button
>
{/each}
</nav>
<div class="rss-result-summary" aria-live="polite">
<strong>{filteredEntries.length} verfügbare Beiträge</strong>
<span>Seite {page} von {pageCount}</span>
</div>
<div
class="rss-entry-list"
role="listbox"
aria-label="Verfügbare RSS-Beiträge"
aria-multiselectable="true"
>
{#each visibleEntries as entry (entry.id)}
<div
class:selected={selectedIds.includes(entry.id)}
class="rss-entry"
role="option"
tabindex="0"
aria-selected={selectedIds.includes(entry.id)}
onclick={() => toggleEntry(entry.id)}
onkeydown={(event) => handleEntryKeydown(event, entry.id)}
>
<div class="rss-entry-state" aria-hidden="true">
<AppIcon name={selectedIds.includes(entry.id) ? 'check' : 'plus'} />
</div>
<div class="rss-entry-copy">
<small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
>
<strong>{entry.title}</strong>
{#if entry.summary}<p>{entry.summary}</p>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}>Original öffnen</a
>
</div>
{#if selectedIds.includes(entry.id)}<span class="rss-selected-label">Ausgewählt</span
>{/if}
</div>
{:else}<p class="empty-copy">
Für diese Quellenauswahl sind keine Beiträge verfügbar.
</p>{/each}
</div>
{#if pageCount > 1}<nav class="rss-pagination" aria-label="RSS-Beitragsseiten">
<button
class="secondary-button"
type="button"
disabled={page === 1}
onclick={() => (page -= 1)}>Zurück</button
>
<span
>{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filteredEntries.length)} von {filteredEntries.length}</span
>
<button
class="secondary-button"
type="button"
disabled={page === pageCount}
onclick={() => (page += 1)}>Weiter</button
>
</nav>{/if}
<div class="rss-category-selection">
<span class="field-label">Kategorien für die Auswahl</span>
<small>Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle.</small>
<ChoicePicker
items={categories}
selectedIds={categoryIds}
label="Kategorien"
fieldName="categoryIds"
onselectionchange={(ids) => (categoryIds = ids)}
/>
</div>
</div>
<footer class="rss-selection-actions">
<button
class="text-button danger-text"
type="submit"
formaction="?/discardFeeds"
disabled={!selectedIds.length}>Auswahl verwerfen</button
>
<button class="primary-button" type="submit" disabled={!selectedIds.length}>
Auswahl in den Eingang übernehmen ({selectedIds.length})
</button>
</footer>
</form>
</dialog>
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
type TechnicalAccess = {
id: string;
name: string;
permissions: 'read' | 'read_write';
};
let {
form = null
}: {
form?: {
intent?: string;
error?: string;
success?: string | boolean;
technicalToken?: string;
} | null;
} = $props();
let dialog: HTMLDialogElement;
let nameInput = $state<HTMLInputElement>();
let copyMessage = $state('');
let access = $state<TechnicalAccess | null>(null);
let name = $state('');
let permissions = $state<'read' | 'read_write'>('read');
onMount(() => {
if (form?.intent === 'technicalAccess' && (form.error || form.technicalToken)) void showModal();
});
export async function showModal(selectedAccess: TechnicalAccess | null = null) {
access = selectedAccess;
name = selectedAccess?.name ?? '';
permissions = selectedAccess?.permissions ?? 'read';
if (!dialog.open) dialog.showModal();
await tick();
(form?.technicalToken && !access
? dialog.querySelector<HTMLButtonElement>('.token-copy')
: nameInput
)?.focus();
}
function close() {
dialog.close();
}
async function copyToken() {
if (!form?.technicalToken) return;
try {
await navigator.clipboard.writeText(form.technicalToken);
copyMessage = 'Token kopiert.';
} catch {
copyMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
</script>
<dialog
bind:this={dialog}
class="management-dialog technical-access-dialog"
aria-labelledby="technical-access-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name="key" /></span>
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2 id="technical-access-dialog-title">{access?.name || 'API-Zugang hinzufügen'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Zugangsdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
{#if form?.technicalToken && !access}
<div class="technical-token-result">
<div class="form-message success" role="status">
<strong>Token, einmalig sichtbar</strong>
<p>Jetzt kopieren und sicher hinterlegen. Sendewerk speichert weiterhin nur den Hash.</p>
</div>
<code>{form.technicalToken}</code>
<button class="primary-button token-copy" type="button" onclick={copyToken}
><AppIcon name="copy" /> Token kopieren</button
>
{#if copyMessage}<small role="status">{copyMessage}</small>{/if}
</div>
{:else}
<form
class="management-dialog-form"
method="POST"
action={access ? '?/updateTechnicalAccess' : '?/createTechnicalAccess'}
>
{#if access}<input type="hidden" name="accessId" value={access.id} />{/if}
{#if form?.intent === 'technicalAccess' && form.error}<div
class="form-message error dialog-form-message"
role="alert"
>
{form.error}
</div>{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
>
<label
>Berechtigung<select bind:value={permissions} name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{access ? 'Änderungen speichern' : 'Zugang anlegen'}</button
>
</footer>
</form>
{/if}
</dialog>
+5 -1
View File
@@ -7,8 +7,9 @@ import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
clearPresentationChannel,
getPresentationChannelView,
getLiveView,
getPresentationChannelStatus,
getPresentationChannelView,
getPresentationMedia,
getPresentationView,
goBack,
@@ -159,7 +160,9 @@ describe('live mode', () => {
});
it('keeps one stable output channel until its key is explicitly rotated', () => {
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
const key = issuePresentationAccess('show-october');
expect(getPresentationChannelStatus('show-october')).toBe('connected');
expect(issuePresentationAccess('show-october')).toBe(key);
expect(presentationChannelAccess('default', key)).toBe(true);
expect(getPresentationChannelView('default')).toEqual({
@@ -175,6 +178,7 @@ describe('live mode', () => {
presentUsage('show-october', 'usage-console', 'present-channel', 1);
expect(getPresentationChannelView('default')?.content).not.toBeNull();
clearPresentationChannel();
expect(getPresentationChannelStatus('show-october')).toBe('disconnected');
expect(getPresentationChannelView('default')?.content).toBeNull();
issuePresentationAccess('show-october');
expect(getPresentationChannelView('default')?.content).toBeNull();
+8
View File
@@ -1158,6 +1158,14 @@ export function presentationChannelAccess(channelId: string, accessKey: string)
);
}
export function getPresentationChannelStatus(showId: string) {
const channel = getDatabase()
.prepare("SELECT show_id FROM presentation_channels WHERE id = 'default'")
.get() as { show_id: string | null } | undefined;
if (!channel?.show_id) return 'disconnected' as const;
return channel.show_id === showId ? ('connected' as const) : ('other_show' as const);
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const channel = database
+7 -1
View File
@@ -9,7 +9,8 @@ import {
authenticateTechnical,
createTechnicalAccess,
idempotentResponse,
revokeTechnicalAccess
revokeTechnicalAccess,
updateTechnicalAccess
} from './technical-access.js';
let directory: string;
@@ -64,6 +65,11 @@ describe('technical API access', () => {
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id)
).not.toMatchObject({ token_hash: created.token });
updateTechnicalAccess(actor, created.id, 'Leser bearbeitet', 'read_write');
expect(authenticateTechnical(request(created.token), true)).toMatchObject({
name: 'Leser bearbeitet',
permissions: 'read_write'
});
revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
});
+25
View File
@@ -48,6 +48,31 @@ export function createTechnicalAccess(actor: AuthUser, nameValue: string, permis
return { id, token };
}
export function updateTechnicalAccess(
actor: AuthUser,
id: string,
nameValue: string,
permissions: string
) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const result = getDatabase()
.prepare(
`UPDATE technical_accesses SET name = ?, permissions = ?
WHERE id = ? AND revoked_at_utc IS NULL`
)
.run(name, permissions, id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.update',
'technical_access',
id,
'user'
);
}
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase()
.prepare(