Release 0.7.2
This commit is contained in:
1 parent
64f55a8b1f
commit
d5b1c58ba2
23 files changed
+1630
-455
No files matched your search
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user