Release 0.7.1
This commit is contained in:
1 parent
6d96900d81
commit
a08b02c34b
29 files changed
+1078
-401
No files matched your search
@@ -6,6 +6,7 @@
|
||||
$props();
|
||||
let query = $state('');
|
||||
let menu: HTMLDetailsElement;
|
||||
let searchInput: HTMLInputElement;
|
||||
let filtered = $derived(
|
||||
classicRegularIcons.filter((icon) =>
|
||||
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
|
||||
@@ -18,7 +19,12 @@
|
||||
|
||||
<div class="icon-picker">
|
||||
<input type="hidden" name={inputName} value={name} />
|
||||
<details bind:this={menu}>
|
||||
<details
|
||||
bind:this={menu}
|
||||
ontoggle={() => {
|
||||
if (menu.open) queueMicrotask(() => searchInput.focus());
|
||||
}}
|
||||
>
|
||||
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<div class="icon-picker-popover">
|
||||
<label
|
||||
@@ -26,6 +32,7 @@
|
||||
type="search"
|
||||
placeholder="Icon suchen"
|
||||
bind:value={query}
|
||||
bind:this={searchInput}
|
||||
/></label
|
||||
>
|
||||
<div class="icon-picker-grid">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type ShareShow = {
|
||||
@@ -31,7 +32,12 @@
|
||||
shows: ShareShow[];
|
||||
shares: ShareItem[];
|
||||
fixedShowId?: string;
|
||||
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
|
||||
form?: {
|
||||
intent?: string;
|
||||
error?: string;
|
||||
success?: string | boolean;
|
||||
shareUrl?: string;
|
||||
} | null;
|
||||
} = $props();
|
||||
|
||||
let visibleShows = $derived(
|
||||
@@ -40,6 +46,17 @@
|
||||
: shows.filter((show) => shares.some((share) => share.showId === show.id))
|
||||
);
|
||||
let dialog: HTMLDialogElement;
|
||||
let copyMessage = $state('');
|
||||
|
||||
onMount(() => {
|
||||
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
|
||||
});
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!form?.shareUrl) return;
|
||||
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
|
||||
copyMessage = 'Link kopiert.';
|
||||
}
|
||||
|
||||
function germanDate(value: string | null, fallback = 'Termin offen') {
|
||||
return value
|
||||
@@ -87,174 +104,206 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
|
||||
<button
|
||||
class="secondary-button share-dialog-trigger"
|
||||
type="button"
|
||||
onclick={() => dialog.showModal()}
|
||||
>
|
||||
<AppIcon name="link" /> Lesefreigaben
|
||||
</button>
|
||||
<dialog bind:this={dialog} class="share-management-dialog">
|
||||
<section
|
||||
class="quiet-panel share-management"
|
||||
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
|
||||
>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Gastzugänge</p>
|
||||
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
|
||||
<section
|
||||
class="quiet-panel share-management"
|
||||
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
|
||||
>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Gastzugänge</p>
|
||||
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
|
||||
</div>
|
||||
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
|
||||
<button
|
||||
class="dialog-close"
|
||||
type="button"
|
||||
aria-label="Lesefreigaben schließen"
|
||||
onclick={() => dialog.close()}
|
||||
>
|
||||
<AppIcon name="xmark" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
|
||||
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
|
||||
<AppIcon name="xmark" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="share-intro">
|
||||
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
|
||||
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
|
||||
</p>
|
||||
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
|
||||
class="form-message error"
|
||||
role="alert"
|
||||
>
|
||||
{form.error}
|
||||
</div>{/if}
|
||||
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
|
||||
class="form-message success"
|
||||
role="status"
|
||||
>
|
||||
{form.success}
|
||||
</div>{/if}
|
||||
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
|
||||
<strong>Neuer Leselink</strong>
|
||||
<!-- The server returns only a same-origin share path created by this action. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={form.shareUrl}>{form.shareUrl}</a>
|
||||
</div>{/if}
|
||||
<p class="share-intro">
|
||||
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
|
||||
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
|
||||
</p>
|
||||
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
|
||||
class="form-message error"
|
||||
role="alert"
|
||||
>
|
||||
{form.error}
|
||||
</div>{/if}
|
||||
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
|
||||
class="form-message success"
|
||||
role="status"
|
||||
>
|
||||
{form.success}
|
||||
</div>{/if}
|
||||
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
|
||||
<strong>Neuer Leselink</strong>
|
||||
<!-- The server returns only a same-origin share path created by this action. -->
|
||||
<div>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={form.shareUrl}>{form.shareUrl}</a><button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
|
||||
>
|
||||
</div>
|
||||
{#if copyMessage}<small>{copyMessage}</small>{/if}
|
||||
</div>{/if}
|
||||
|
||||
<form class="share-create-form" method="POST" action="?/createShare">
|
||||
{#if fixedShowId}
|
||||
<input type="hidden" name="showId" value={fixedShowId} />
|
||||
{:else}
|
||||
<form class="share-create-form" method="POST" action="?/createShare">
|
||||
{#if fixedShowId}
|
||||
<input type="hidden" name="showId" value={fixedShowId} />
|
||||
{:else}
|
||||
<label
|
||||
>Sendung<select name="showId" required>
|
||||
<option value="">Sendung wählen</option>
|
||||
{#each shows as show (show.id)}<option value={show.id}
|
||||
>{show.title} · {statusLabel(show.status)}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
{/if}
|
||||
<label
|
||||
>Sendung<select name="showId" required>
|
||||
<option value="">Sendung wählen</option>
|
||||
{#each shows as show (show.id)}<option value={show.id}
|
||||
>{show.title} · {statusLabel(show.status)}</option
|
||||
>{/each}
|
||||
>Informationsumfang<select name="scope">
|
||||
<option value="rundown">Nur Ablauf</option>
|
||||
<option value="moderation">Ablauf und Moderation</option>
|
||||
<option value="research">Vollständig mit Recherche</option>
|
||||
</select></label
|
||||
>
|
||||
{/if}
|
||||
<label
|
||||
>Informationsumfang<select name="scope">
|
||||
<option value="rundown">Nur Ablauf</option>
|
||||
<option value="moderation">Ablauf und Moderation</option>
|
||||
<option value="research">Vollständig mit Recherche</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Optionales Passwort<input
|
||||
name="password"
|
||||
type="password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
autocomplete="new-password"
|
||||
/></label
|
||||
>
|
||||
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
|
||||
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
|
||||
</form>
|
||||
<label
|
||||
>Optionales Passwort<input
|
||||
name="password"
|
||||
type="password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
autocomplete="new-password"
|
||||
/></label
|
||||
>
|
||||
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
|
||||
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
|
||||
>
|
||||
</form>
|
||||
|
||||
{#if visibleShows.length}
|
||||
<div class="share-show-list">
|
||||
{#each visibleShows as show (show.id)}
|
||||
<article class="share-show-card">
|
||||
<header>
|
||||
<div>
|
||||
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
|
||||
<small>{germanDate(show.startsAtUtc)} Uhr</small>
|
||||
</div>
|
||||
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
|
||||
</header>
|
||||
<div class="share-list">
|
||||
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
|
||||
<details class="share-row">
|
||||
<summary>
|
||||
<span class="share-row-icon"><AppIcon name="link" /></span>
|
||||
<span>
|
||||
<strong>{scopeLabel(share.scope)}</strong>
|
||||
<small
|
||||
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
|
||||
share.expiresAtUtc,
|
||||
'ohne Ablauf'
|
||||
)}</small
|
||||
{#if visibleShows.length}
|
||||
<div class="share-show-list">
|
||||
{#each visibleShows as show (show.id)}
|
||||
<article class="share-show-card">
|
||||
<header>
|
||||
<div>
|
||||
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
|
||||
<small>{germanDate(show.startsAtUtc)} Uhr</small>
|
||||
</div>
|
||||
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
|
||||
</header>
|
||||
<div class="share-list">
|
||||
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
|
||||
<details class="share-row">
|
||||
<summary>
|
||||
<span class="share-row-icon"><AppIcon name="link" /></span>
|
||||
<span>
|
||||
<strong>{scopeLabel(share.scope)}</strong>
|
||||
<small
|
||||
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
|
||||
share.expiresAtUtc,
|
||||
'ohne Ablauf'
|
||||
)}</small
|
||||
>
|
||||
</span>
|
||||
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
|
||||
<AppIcon name="pen" />
|
||||
</summary>
|
||||
<div class="share-edit-area">
|
||||
{#if share.revokedAtUtc}
|
||||
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
|
||||
{:else}
|
||||
<form class="share-edit-form" method="POST" action="?/updateShare">
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<label
|
||||
>Informationsumfang<select name="scope" value={share.scope}>
|
||||
<option value="rundown">Nur Ablauf</option>
|
||||
<option value="moderation">Ablauf und Moderation</option>
|
||||
<option value="research">Vollständig mit Recherche</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Passwort<select name="passwordMode">
|
||||
<option value="keep">Unverändert</option>
|
||||
<option value="remove">Kein Passwort</option>
|
||||
<option value="replace">Neues Passwort setzen</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Neues Passwort<input
|
||||
name="password"
|
||||
type="password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
autocomplete="new-password"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Ablauf<input
|
||||
name="expiresAt"
|
||||
type="datetime-local"
|
||||
value={berlinInput(share.expiresAtUtc)}
|
||||
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Freigabe speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if !share.revokedAtUtc}<form
|
||||
method="POST"
|
||||
action="?/replaceShare"
|
||||
onsubmit={(event) => {
|
||||
if (
|
||||
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
|
||||
)
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<button class="text-button" type="submit">Ersatzlink anlegen</button>
|
||||
</form>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteShare"
|
||||
onsubmit={(event) => {
|
||||
if (
|
||||
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
|
||||
)
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
</span>
|
||||
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
|
||||
<AppIcon name="pen" />
|
||||
</summary>
|
||||
<div class="share-edit-area">
|
||||
{#if share.revokedAtUtc}
|
||||
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
|
||||
{:else}
|
||||
<form class="share-edit-form" method="POST" action="?/updateShare">
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<label
|
||||
>Informationsumfang<select name="scope" value={share.scope}>
|
||||
<option value="rundown">Nur Ablauf</option>
|
||||
<option value="moderation">Ablauf und Moderation</option>
|
||||
<option value="research">Vollständig mit Recherche</option>
|
||||
</select></label
|
||||
<button class="text-button danger-text" type="submit">Freigabe löschen</button
|
||||
>
|
||||
<label
|
||||
>Passwort<select name="passwordMode">
|
||||
<option value="keep">Unverändert</option>
|
||||
<option value="remove">Kein Passwort</option>
|
||||
<option value="replace">Neues Passwort setzen</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Neues Passwort<input
|
||||
name="password"
|
||||
type="password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
autocomplete="new-password"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Ablauf<input
|
||||
name="expiresAt"
|
||||
type="datetime-local"
|
||||
value={berlinInput(share.expiresAtUtc)}
|
||||
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Freigabe speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteShare"
|
||||
onsubmit={(event) => {
|
||||
if (
|
||||
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
|
||||
)
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
{#if !shares.some((share) => share.showId === show.id)}
|
||||
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
{#if !shares.some((share) => share.showId === show.id)}
|
||||
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</dialog>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||
import InlineTitleEditor from './InlineTitleEditor.svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
@@ -13,7 +13,8 @@
|
||||
sectionId,
|
||||
sections,
|
||||
error,
|
||||
enhanceCommand
|
||||
enhanceCommand,
|
||||
open = false
|
||||
}: {
|
||||
usage: PieceUsage;
|
||||
revision: number;
|
||||
@@ -21,6 +22,7 @@
|
||||
sections: { id: string; name: string }[];
|
||||
error?: string;
|
||||
enhanceCommand: SubmitFunction;
|
||||
open?: boolean;
|
||||
} = $props();
|
||||
|
||||
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||
@@ -30,6 +32,9 @@
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
onMount(() => {
|
||||
if (open) editor.showModal();
|
||||
});
|
||||
let selectedMedia = $derived(
|
||||
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||
);
|
||||
@@ -44,6 +49,15 @@
|
||||
editor.close();
|
||||
}
|
||||
|
||||
function presentationSourceChanged(event: Event) {
|
||||
const index = Number((event.currentTarget as HTMLSelectElement).value);
|
||||
const source = draft.sources[index];
|
||||
if (!source) return;
|
||||
draft.presentationSourceLabel = source.label;
|
||||
draft.presentationSourceUrl = source.url;
|
||||
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
|
||||
}
|
||||
|
||||
const enhanceEditor: SubmitFunction = async (input) => {
|
||||
const finish = await enhanceCommand(input);
|
||||
return async (result) => {
|
||||
@@ -212,20 +226,7 @@
|
||||
</div>
|
||||
<div class="piece-presentation-layout">
|
||||
<div class="piece-presentation-controls">
|
||||
<label
|
||||
>Präsentationstitel<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentation"
|
||||
label="Öffentlicher Text"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
bind:value={draft.presentation}
|
||||
/>
|
||||
<p class="field-label">Präsentationsart</p>
|
||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
||||
<label
|
||||
@@ -238,6 +239,25 @@
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if draft.presentationMode === 'text'}
|
||||
<label
|
||||
>Öffentliche Überschrift<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentation"
|
||||
label="Öffentlicher Vorspann"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
bind:value={draft.presentation}
|
||||
/>
|
||||
{:else}
|
||||
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
|
||||
<input type="hidden" name="presentation" value={draft.presentation} />
|
||||
{/if}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceLabel"
|
||||
@@ -253,32 +273,42 @@
|
||||
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
|
||||
<div class="presentation-settings">
|
||||
<div class="presentation-source-summary wide-field">
|
||||
<span>Ausgewählte Quelle</span>
|
||||
<strong
|
||||
>{draft.presentationSourceLabel ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Keine Quelle'}</strong
|
||||
<label class="wide-field"
|
||||
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
|
||||
onchange={presentationSourceChanged}
|
||||
>
|
||||
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
|
||||
</div>
|
||||
{#each draft.sources as source, index (source.id)}
|
||||
<option value={index} selected={source.url === draft.presentationSourceUrl}
|
||||
>{source.label || source.url}</option
|
||||
>
|
||||
{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Kartentitel<input
|
||||
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
|
||||
name="presentationTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optionaler Anzeigetitel"
|
||||
bind:value={draft.presentationTitle}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Bild-URL<input
|
||||
{#if draft.presentationMode === 'link'}
|
||||
<label
|
||||
>Vorschaubild-URL<input
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="Aus der Linkvorschau"
|
||||
bind:value={draft.presentationImageUrl}
|
||||
/></label
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
bind:value={draft.presentationImageUrl}
|
||||
/></label
|
||||
>
|
||||
value={draft.presentationImageUrl}
|
||||
/>
|
||||
{/if}
|
||||
<label class="wide-field"
|
||||
>Anreißer<textarea
|
||||
name="presentationDescription"
|
||||
@@ -298,7 +328,9 @@
|
||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if draft.media.length}<fieldset class="usage-media-picker">
|
||||
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
|
||||
class="usage-media-picker"
|
||||
>
|
||||
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||
<label
|
||||
><input
|
||||
@@ -336,19 +368,20 @@
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{/if}
|
||||
{:else}
|
||||
{:else if draft.presentationMode === 'image'}
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{:else if draft.presentationImageUrl}<img
|
||||
src={draft.presentationImageUrl}
|
||||
alt=""
|
||||
/>{/if}
|
||||
{:else if draft.presentationMode === 'link'}
|
||||
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
|
||||
<p>
|
||||
{draft.presentationDescription ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Noch keine Vorschauangaben.'}
|
||||
</p>
|
||||
{:else}
|
||||
<p>{draft.presentationSourceUrl || 'Noch keine Video-URL ausgewählt.'}</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
moveOpenUsage,
|
||||
presentationChannelAccess,
|
||||
presentUsage,
|
||||
resumeUsage,
|
||||
rotatePresentationChannelKey,
|
||||
selectUsage,
|
||||
skipCurrent,
|
||||
@@ -59,6 +60,11 @@ describe('live mode', () => {
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
plannedDurationSeconds: 600,
|
||||
elapsedSeconds: 100
|
||||
});
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-next-piece',
|
||||
@@ -104,13 +110,54 @@ describe('live mode', () => {
|
||||
.find((usage) => usage.id === 'usage-console')?.status
|
||||
).toBe('done');
|
||||
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
|
||||
expect(() =>
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
|
||||
).toThrow('INVALID_STATUS');
|
||||
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
|
||||
expect(getLiveView('show-october').current).toMatchObject({
|
||||
id: 'usage-console',
|
||||
status: 'current'
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes a completed piece and its earlier section timer without resetting total time', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
database
|
||||
.prepare("UPDATE show_sections SET explicit_duration_seconds = 200 WHERE id = 'section-game'")
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE piece_usages SET show_section_id = 'section-game', position = 0 WHERE id = 'usage-effects'"
|
||||
)
|
||||
.run();
|
||||
const start = new Date('2026-10-23T18:00:00.000Z');
|
||||
startOrResumeLive('show-october', 'resume-start', 0, start);
|
||||
completeCurrent('show-october', 'resume-next', 1, new Date('2026-10-23T18:01:00.000Z'));
|
||||
let live = getLiveView('show-october', new Date('2026-10-23T18:01:30.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
resumeUsage(
|
||||
'show-october',
|
||||
'usage-console',
|
||||
'resume-earlier',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:01:30.000Z')
|
||||
);
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:02:00.000Z'));
|
||||
expect(live.current).toMatchObject({ id: 'usage-console', status: 'current' });
|
||||
expect(live.completedCount).toBe(0);
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
sectionId: 'section-stories',
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 90
|
||||
});
|
||||
expect(live.totalTimer.elapsedSeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('keeps one stable output channel until its key is explicitly rotated', () => {
|
||||
const key = issuePresentationAccess('show-october');
|
||||
expect(issuePresentationAccess('show-october')).toBe(key);
|
||||
|
||||
+70
-11
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
|
||||
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT s.is_reserve_bank FROM piece_usages u
|
||||
`SELECT s.is_reserve_bank, u.status FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ?`
|
||||
)
|
||||
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
|
||||
.get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
|
||||
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
|
||||
return row;
|
||||
}
|
||||
|
||||
function closeActiveRun(database: Database.Database, showId: string, now: string) {
|
||||
@@ -265,8 +266,7 @@ function syncSectionTimer(
|
||||
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as
|
||||
| { id: string; section_id: string; planned_duration_seconds: number }
|
||||
| undefined;
|
||||
{ id: string; section_id: string; planned_duration_seconds: number } | undefined;
|
||||
if (active?.section_id === section.id) {
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
|
||||
closeSectionTimer(database, showId, now);
|
||||
@@ -279,6 +279,29 @@ function syncSectionTimer(
|
||||
}
|
||||
if (active) closeSectionTimer(database, showId, now);
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
|
||||
const previous = database
|
||||
.prepare(
|
||||
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
|
||||
ORDER BY started_at_utc DESC LIMIT 1`
|
||||
)
|
||||
.get(showId, section.id) as { id: string } | undefined;
|
||||
if (previous) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(section.explicit_duration_seconds, previous.id);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), previous.id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
@@ -287,12 +310,14 @@ function syncSectionTimer(
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
@@ -412,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const state = ensureLiveState(database, showId, now);
|
||||
const show = getShow(showId);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
|
||||
syncSectionTimer(database, showId, state.current_usage_id, now);
|
||||
}
|
||||
const durations = actualDurations(database, showId, now);
|
||||
const sections = show.sections.map((section) => ({
|
||||
...section,
|
||||
@@ -941,7 +969,8 @@ export function selectUsage(
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc) throw new Error('NOT_STARTED');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'open') throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id === usageId) return;
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
@@ -955,6 +984,36 @@ export function selectUsage(
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'resume-usage',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'done' && target.status !== 'skipped')
|
||||
throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
|
||||
.run(state.current_usage_id);
|
||||
}
|
||||
activateUsage(database, showId, usageId, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function presentUsage(
|
||||
showId: string,
|
||||
usageId: string | null,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getSharedMedia,
|
||||
getSharedShow,
|
||||
revokeShare,
|
||||
replaceShareToken,
|
||||
updateShare
|
||||
} from './shares.js';
|
||||
import { addPieceUsage, addShowSection, createShow } from './shows.js';
|
||||
@@ -138,8 +139,17 @@ describe('show shares', () => {
|
||||
expect(getSharedShow(shared.token).status).toBe('password');
|
||||
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
|
||||
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
|
||||
deleteShare(actor, shared.id, created.id);
|
||||
const replacementToken = replaceShareToken(actor, shared.id, created.id);
|
||||
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
|
||||
expect(getSharedShow(replacementToken).status).toBe('password');
|
||||
const replacementSession = await authenticateShare(
|
||||
replacementToken,
|
||||
'neues-freigabe-passwort',
|
||||
'192.0.2.20'
|
||||
);
|
||||
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('authorized');
|
||||
deleteShare(actor, shared.id, created.id);
|
||||
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('projects exactly the selected information scope', async () => {
|
||||
|
||||
@@ -193,6 +193,27 @@ export async function updateShare(
|
||||
})();
|
||||
}
|
||||
|
||||
export function replaceShareToken(
|
||||
actor: AuthUser,
|
||||
shareId: string,
|
||||
expectedShowId?: string
|
||||
): string {
|
||||
requireBarkeeperActor(actor);
|
||||
const token = createToken();
|
||||
const database = getDatabase();
|
||||
database.transaction(() => {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE show_shares SET token_hash = ?
|
||||
WHERE id = ? AND revoked_at_utc IS NULL${expectedShowId ? ' AND show_id = ?' : ''}`
|
||||
)
|
||||
.run(hashToken(token), ...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
|
||||
})();
|
||||
return token;
|
||||
}
|
||||
|
||||
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
|
||||
requireBarkeeperActor(actor);
|
||||
const result = getDatabase()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
duplicatePieceUsage,
|
||||
getPieceUsageContexts,
|
||||
getShow,
|
||||
listPreparationUsages,
|
||||
listShowTemplates,
|
||||
movePieceUsage,
|
||||
moveShowSection,
|
||||
@@ -66,6 +67,27 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
||||
}
|
||||
|
||||
describe('show builder data layer', () => {
|
||||
it('lists only scheduled preparing originals as concrete usage snapshots', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
|
||||
)
|
||||
.run();
|
||||
const usages = listPreparationUsages();
|
||||
|
||||
expect(usages.map((usage) => usage.usageId)).toEqual(['usage-games-console', 'usage-console']);
|
||||
expect(usages[1]).toMatchObject({
|
||||
showId: 'show-october',
|
||||
showTitle: 'Herbstsendung: Kleine Welten',
|
||||
sectionName: 'Kleine Welten',
|
||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||
preparationEffort: 'high',
|
||||
topics: [{ name: 'Gaming' }]
|
||||
});
|
||||
expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a fundus original and usage atomically and idempotently', () => {
|
||||
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(created.id, created.revision, {
|
||||
|
||||
@@ -21,6 +21,17 @@ export type ShowListItem = {
|
||||
sectionCount: number;
|
||||
};
|
||||
|
||||
export type PreparationUsage = {
|
||||
usageId: string;
|
||||
showId: string;
|
||||
showTitle: string;
|
||||
showStartsAtUtc: string | null;
|
||||
sectionName: string;
|
||||
title: string;
|
||||
preparationEffort: 'low' | 'medium' | 'high' | null;
|
||||
topics: UsageTopic[];
|
||||
};
|
||||
|
||||
export type ShowTemplateSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -769,6 +780,50 @@ export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function listPreparationUsages(): PreparationUsage[] {
|
||||
const database = getDatabase();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
|
||||
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
|
||||
u.title_snapshot, p.preparation_effort
|
||||
FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN shows sh ON sh.id = s.show_id
|
||||
JOIN pieces p ON p.id = u.original_piece_id
|
||||
WHERE sh.status IN ('draft', 'ready', 'live')
|
||||
AND s.is_reserve_bank = 0 AND p.deleted_at_utc IS NULL AND p.status = 'preparing'
|
||||
ORDER BY CASE p.preparation_effort
|
||||
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END,
|
||||
sh.starts_at_utc IS NULL, sh.starts_at_utc, s.position, u.position`
|
||||
)
|
||||
.all() as {
|
||||
usage_id: string;
|
||||
show_id: string;
|
||||
show_title: string;
|
||||
show_starts_at_utc: string | null;
|
||||
section_name: string;
|
||||
title_snapshot: string;
|
||||
preparation_effort: PreparationUsage['preparationEffort'];
|
||||
}[];
|
||||
const topics = database.prepare(
|
||||
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
usageId: row.usage_id,
|
||||
showId: row.show_id,
|
||||
showTitle: row.show_title,
|
||||
showStartsAtUtc: row.show_starts_at_utc,
|
||||
sectionName: row.section_name,
|
||||
title: row.title_snapshot,
|
||||
preparationEffort: row.preparation_effort,
|
||||
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
|
||||
name: topic.name,
|
||||
iconName: topic.icon_name
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function listShowTemplates(): ShowTemplate[] {
|
||||
const database = getDatabase();
|
||||
const templates = database
|
||||
|
||||
Reference in new issue
Block a user