Release 0.7.1

This commit is contained in:
hnzio committed 2026-10-25 23:29:00 +01:00
1 parent 6d96900d81
commit a08b02c34b
29 files changed
+791 -114

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.0", "version": "0.7.1",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+20
View File
@@ -1,5 +1,25 @@
# Abnahme # Abnahme
## Wartungsrelease 0.7.1
| Bereich | Nachweis | Stand |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Abschnittsdauer und Rückkehr zu früheren Pieces (#1) | Live-Verhaltenstests für geänderte Plandauer, Wiederaufnahme, Abschnittslaufzeit und Gesamtzeit; Chromium-Ablauf | erfüllt am 25.10.2026 |
| Mobiler Live-Ablauf und Metadaten (#3, #13) | kleine Live-Regie bei 390 x 700 ohne horizontalen Überlauf; Sheet, Fokus und Viewportgrenzen im Chromium-Ablauf | Chromium erfüllt; Safari und installierte iOS-Webapp offen |
| Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 25.10.2026 |
| Vorbereitung nach Einplanung (#6, #15) | Servertest schließt ungeplante Pieces aus und liefert konkrete Usage-Snapshots priorisiert mit Themenicons | erfüllt am 25.10.2026 |
| Kontextabhängige Präsentation (#8) | Fundus- und Usage-Editor im Chromium-Lauf; Text-, Bild-, Link- und Videozustände durch bestehende Verhaltenstests gedeckt | erfüllt am 25.10.2026 |
| Gastansicht und Freigaben (#11, #12) | Gastansicht mobil/desktop, erneut geöffnete Dialogergebnisse und Tokenersatz mit Invalidierungstest | erfüllt am 25.10.2026 |
| Iconsuche (#14) | Chromium prüft sichtbares und unmittelbar fokussiertes Suchfeld bei 390, 960 und 1280 Pixeln | erfüllt am 25.10.2026 |
- `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 18 von 18 Chromium-Abläufen erfolgreich.
- Safari auf echtem iOS-Gerät: offen.
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
## Etappe 7 ## Etappe 7
| Kriterium | Nachweis | Stand | | Kriterium | Nachweis | Stand |
+14
View File
@@ -450,3 +450,17 @@ bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte `image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium. Präsentationsmedium.
## Wartungskorrekturen 0.7.1
Die Vorbereitungsliste projiziert bestehende Daten statt einen zweiten
Vorbereitungszustand einzuführen: Sie verbindet aktive Sendungen mit ihren
Usage-Snapshots und berücksichtigt nur Originale im Zustand `preparing`.
Dadurch erscheinen Mehrfachplanungen als getrennte Sendungsfassungen, während
ungeplante Fundus-Pieces ausgeschlossen bleiben. Der Link adressiert die Usage
direkt; Titel und Themenicons stammen aus dem stabilen Snapshot.
Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben
Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und
alle bisherigen Gastsitzungen werden in derselben Transaktion ungültig. Der neue
Klartexttoken wird weiterhin nur in der Action-Antwort ausgegeben.
+12
View File
@@ -492,3 +492,15 @@ abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne oder ein bestimmter KI-Client wird nicht vorgetäuscht: Die Anwendung bleibt ohne
KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft KI vollständig nutzbar und eine konkrete Client-Verbindung muss separat geprüft
werden. werden.
## D-031: Usage-basierte Vorbereitung und rotierende Freigabelinks
Die Sendungsübersicht erzeugt keinen zusätzlichen Vorbereitungszustand. Sie
projiziert konkrete Usage-Snapshots aktiver Sendungen, deren Fundus-Original noch
`preparing` ist. So bleiben bestehende Daten und Snapshots unverändert, während
ungeplante Fundus-Arbeit aus der Sendungsübersicht verschwindet.
Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und
Ablauf werden nicht stillschweigend verändert.
+24
View File
@@ -1,5 +1,29 @@
# Release Notes # Release Notes
## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
- „Noch vorzubereiten“ zeigt nur tatsächlich eingeplante unfertige Fassungen
aktiver Sendungen, priorisiert sie und verlinkt sie mit ihren Themenicons direkt
in den Usage-Editor (#6, #15).
- Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video
durch kontextsensitive Eingaben und passende Vorschauen (#8).
- Gastabschnitte verzichten auf unnötige äußere Karten. Freigabedialoge zeigen
den einmaligen Link nach dem Anlegen sofort wieder an, bieten Kopieren und
können einen alten Token sicher durch einen neuen ersetzen (#11, #12).
- Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14).
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Es gibt keine
neue Datenbankmigration. 60 Verhaltenstests und 18 Chromium-Abläufe sind
erfolgreich; echtes Safari und die installierte Webapp auf iOS bleiben offen.
## 0.7.0 - RSS-Import und externe Assistenz ## 0.7.0 - RSS-Import und externe Assistenz
Veröffentlicht am 25. Oktober 2026 Veröffentlicht am 25. Oktober 2026
+27
View File
@@ -8,6 +8,33 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
dokumentiert. dokumentiert.
## Fehlerkorrekturen 0.7.1
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, und lange
Metadaten verbreitern die Regie nicht mehr. Sendungskarten stapeln ihre Inhalte
auf kleinen Bildschirmen eindeutig.
- Die Vorbereitung zeigt nur konkrete Einplanungen aktiver Sendungen, deren
Fundus-Original noch in Vorbereitung ist. Usage-Titel, Sendung, Abschnitt,
Priorität und gespeicherte Themenicons führen direkt zur Sendungsfassung.
- Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
Moduswechsel erhalten.
- Die Iconsuche fokussiert beim Öffnen sofort das Suchfeld. RSS-Feeds und
technische Zugänge folgen in der Verwaltung dem kompakten Katalogmuster.
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
alten Token und dessen Gastsitzungen. Die Gastansicht trennt Abschnitte ohne
zusätzliche äußere Karten.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
18 von 18 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
echten iOS-Gerät bleiben mangels Gerät getrennt offen.
## RSS-Import und externe Assistenz 0.7.0 ## RSS-Import und externe Assistenz 0.7.0
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und - Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.7.0", "version": "0.7.1",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+44 -5
View File
@@ -262,6 +262,13 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.share-result > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guest-shell { .guest-shell {
min-height: 100dvh; min-height: 100dvh;
} }
@@ -294,6 +301,11 @@
margin-top: 1.25rem; margin-top: 1.25rem;
} }
.shared-section {
padding: 0 0 1.25rem;
border-bottom: 1px solid var(--line);
}
.shared-section > header, .shared-section > header,
.historical-section > header, .historical-section > header,
.shared-usage-heading, .shared-usage-heading,
@@ -1325,6 +1337,8 @@ textarea:focus {
.live-page { .live-page {
display: grid; display: grid;
position: relative;
isolation: isolate;
width: 100%; width: 100%;
height: 100dvh; height: 100dvh;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto minmax(0, 1fr) auto;
@@ -1796,7 +1810,7 @@ textarea:focus {
.live-piece-heading { .live-piece-heading {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline; align-items: baseline;
gap: 0.35rem 0.65rem; gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1819,6 +1833,7 @@ textarea:focus {
} }
.live-current h1 { .live-current h1 {
min-width: 0;
max-width: none; max-width: none;
margin: 0; margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem); font-size: clamp(1.2rem, 2.2vw, 1.9rem);
@@ -2390,11 +2405,11 @@ textarea:focus {
.live-page.without-rail .live-rail, .live-page.without-rail .live-rail,
.live-rail { .live-rail {
display: grid; display: grid;
position: fixed; position: absolute;
z-index: 80; z-index: 80;
inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right)) inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right))
max(0.4rem, env(safe-area-inset-bottom)) auto; max(0.4rem, env(safe-area-inset-bottom)) auto;
width: min(22rem, calc(100vw - 0.8rem - env(safe-area-inset-left))); width: min(22rem, calc(100% - 0.8rem - env(safe-area-inset-left)));
transform: translateX(calc(100% + 1rem)); transform: translateX(calc(100% + 1rem));
visibility: hidden; visibility: hidden;
transition: transition:
@@ -2414,7 +2429,7 @@ textarea:focus {
.live-rail-backdrop.visible { .live-rail-backdrop.visible {
display: block; display: block;
position: fixed; position: absolute;
z-index: 70; z-index: 70;
inset: 0; inset: 0;
border: 0; border: 0;
@@ -2431,9 +2446,16 @@ textarea:focus {
} }
.live-piece-heading { .live-piece-heading {
min-width: 0;
gap: 0.3rem 0.45rem; gap: 0.3rem 0.45rem;
} }
.live-piece-time {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.live-support-actions { .live-support-actions {
display: grid; display: grid;
} }
@@ -4761,11 +4783,24 @@ textarea:focus {
background: var(--panel-soft); background: var(--panel-soft);
} }
.preparation-list span { .preparation-copy,
.preparation-copy > span:last-child {
display: grid; display: grid;
min-width: 0; min-width: 0;
} }
.preparation-copy {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.6rem;
}
.preparation-topics {
display: flex;
gap: 0.2rem;
color: var(--accent-text);
}
.preparation-list small { .preparation-list small {
overflow: hidden; overflow: hidden;
color: var(--text-muted); color: var(--text-muted);
@@ -6742,6 +6777,10 @@ textarea:focus {
.show-card-facts { .show-card-facts {
align-items: flex-start; align-items: flex-start;
} }
.show-card {
flex-direction: column;
align-items: stretch;
}
.show-card-facts { .show-card-facts {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
+8 -1
View File
@@ -6,6 +6,7 @@
$props(); $props();
let query = $state(''); let query = $state('');
let menu: HTMLDetailsElement; let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived( let filtered = $derived(
classicRegularIcons.filter((icon) => classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}` `${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -18,7 +19,12 @@
<div class="icon-picker"> <div class="icon-picker">
<input type="hidden" name={inputName} value={name} /> <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> <summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover"> <div class="icon-picker-popover">
<label <label
@@ -26,6 +32,7 @@
type="search" type="search"
placeholder="Icon suchen" placeholder="Icon suchen"
bind:value={query} bind:value={query}
bind:this={searchInput}
/></label /></label
> >
<div class="icon-picker-grid"> <div class="icon-picker-grid">
+55 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte'; import AppIcon from './AppIcon.svelte';
type ShareShow = { type ShareShow = {
@@ -31,7 +32,12 @@
shows: ShareShow[]; shows: ShareShow[];
shares: ShareItem[]; shares: ShareItem[];
fixedShowId?: string; 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(); } = $props();
let visibleShows = $derived( let visibleShows = $derived(
@@ -40,6 +46,17 @@
: shows.filter((show) => shares.some((share) => share.showId === show.id)) : shows.filter((show) => shares.some((share) => share.showId === show.id))
); );
let dialog: HTMLDialogElement; 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') { function germanDate(value: string | null, fallback = 'Termin offen') {
return value return value
@@ -87,7 +104,11 @@
} }
</script> </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 <AppIcon name="link" /> Lesefreigaben
</button> </button>
<dialog bind:this={dialog} class="share-management-dialog"> <dialog bind:this={dialog} class="share-management-dialog">
@@ -101,7 +122,12 @@
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2> <h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div> </div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span> <span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}> <button
class="dialog-close"
type="button"
aria-label="Lesefreigaben schließen"
onclick={() => dialog.close()}
>
<AppIcon name="xmark" /> <AppIcon name="xmark" />
</button> </button>
</div> </div>
@@ -124,8 +150,15 @@
{#if form?.shareUrl}<div class="form-message success share-result" role="status"> {#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong> <strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. --> <!-- The server returns only a same-origin share path created by this action. -->
<div>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a> <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} </div>{/if}
<form class="share-create-form" method="POST" action="?/createShare"> <form class="share-create-form" method="POST" action="?/createShare">
@@ -158,7 +191,8 @@
/></label /></label
> >
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label> <label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button> <button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
>
</form> </form>
{#if visibleShows.length} {#if visibleShows.length}
@@ -229,6 +263,20 @@
<button class="secondary-button" type="submit">Freigabe speichern</button> <button class="secondary-button" type="submit">Freigabe speichern</button>
</form> </form>
{/if} {/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 <form
method="POST" method="POST"
action="?/deleteShare" action="?/deleteShare"
@@ -241,7 +289,8 @@
> >
<input type="hidden" name="shareId" value={share.id} /> <input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} /> <input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button> <button class="text-button danger-text" type="submit">Freigabe löschen</button
>
</form> </form>
</div> </div>
</details> </details>
+65 -32
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte'; import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte'; import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
@@ -13,7 +13,8 @@
sectionId, sectionId,
sections, sections,
error, error,
enhanceCommand enhanceCommand,
open = false
}: { }: {
usage: PieceUsage; usage: PieceUsage;
revision: number; revision: number;
@@ -21,6 +22,7 @@
sections: { id: string; name: string }[]; sections: { id: string; name: string }[];
error?: string; error?: string;
enhanceCommand: SubmitFunction; enhanceCommand: SubmitFunction;
open?: boolean;
} = $props(); } = $props();
let formId = $derived(`usage-editor-form-${usage.id}`); let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +32,9 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
); );
let dirty = $state(false); let dirty = $state(false);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived( let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
); );
@@ -44,6 +49,15 @@
editor.close(); 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 enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input); const finish = await enhanceCommand(input);
return async (result) => { return async (result) => {
@@ -212,20 +226,7 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>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}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])} {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label <label
@@ -238,6 +239,25 @@
> >
{/each} {/each}
</div> </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 <input
type="hidden" type="hidden"
name="presentationSourceLabel" name="presentationSourceLabel"
@@ -253,32 +273,42 @@
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} /> <input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'} {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <label class="wide-field"
<span>Ausgewählte Quelle</span> >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
<strong onchange={presentationSourceChanged}
>{draft.presentationSourceLabel || >
draft.presentationSourceUrl || {#each draft.sources as source, index (source.id)}
'Keine Quelle'}</strong <option value={index} selected={source.url === draft.presentationSourceUrl}
>{source.label || source.url}</option
>
{/each}
</select></label
> >
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
<label <label
>Kartentitel<input >{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle} bind:value={draft.presentationTitle}
/></label /></label
> >
{#if draft.presentationMode === 'link'}
<label <label
>Bild-URL<input >Vorschaubild-URL<input
name="presentationImageUrl" name="presentationImageUrl"
type="url" type="url"
maxlength="2000" maxlength="2000"
placeholder="https://…" placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl} bind:value={draft.presentationImageUrl}
/></label /></label
> >
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field" <label class="wide-field"
>Anreißer<textarea >Anreißer<textarea
name="presentationDescription" name="presentationDescription"
@@ -298,7 +328,9 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if} {/if}
</div> </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> <legend>Hochgeladenes Präsentationsbild</legend>
<label <label
><input ><input
@@ -336,19 +368,20 @@
src={`/media/${selectedMedia.originalMediaId}`} src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText} alt={selectedMedia.altText}
/>{/if} />{/if}
{:else} {:else if draft.presentationMode === 'image'}
{#if selectedMedia}<img {#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`} src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText} alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if} />{/if}
{:else if draft.presentationMode === 'link'}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<p> <p>
{draft.presentationDescription || {draft.presentationDescription ||
draft.presentationSourceUrl || draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'} 'Noch keine Vorschauangaben.'}
</p> </p>
{:else}
<p>{draft.presentationSourceUrl || 'Noch keine Video-URL ausgewählt.'}</p>
{/if} {/if}
</aside> </aside>
</div> </div>
+48 -1
View File
@@ -16,6 +16,7 @@ import {
moveOpenUsage, moveOpenUsage,
presentationChannelAccess, presentationChannelAccess,
presentUsage, presentUsage,
resumeUsage,
rotatePresentationChannelKey, rotatePresentationChannelKey,
selectUsage, selectUsage,
skipCurrent, skipCurrent,
@@ -59,6 +60,11 @@ describe('live mode', () => {
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'" "UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
) )
.run(); .run();
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 600,
elapsedSeconds: 100
});
completeCurrent( completeCurrent(
'show-october', 'show-october',
'timer-next-piece', 'timer-next-piece',
@@ -104,13 +110,54 @@ describe('live mode', () => {
.find((usage) => usage.id === 'usage-console')?.status .find((usage) => usage.id === 'usage-console')?.status
).toBe('done'); ).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({ expect(getLiveView('show-october').current).toMatchObject({
id: 'usage-console', id: 'usage-console',
status: 'current' 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', () => { it('keeps one stable output channel until its key is explicitly rotated', () => {
const key = issuePresentationAccess('show-october'); const key = issuePresentationAccess('show-october');
expect(issuePresentationAccess('show-october')).toBe(key); expect(issuePresentationAccess('show-october')).toBe(key);
+64 -5
View File
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) { function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database const row = database
.prepare( .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 JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_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'); if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
return row;
} }
function closeActiveRun(database: Database.Database, showId: string, now: string) { 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` FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as .get(showId) as
| { id: string; section_id: string; planned_duration_seconds: number } { id: string; section_id: string; planned_duration_seconds: number } | undefined;
| undefined;
if (active?.section_id === section.id) { if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) { if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now); closeSectionTimer(database, showId, now);
@@ -279,6 +279,29 @@ function syncSectionTimer(
} }
if (active) closeSectionTimer(database, showId, now); if (active) closeSectionTimer(database, showId, now);
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return; 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(); const timerId = randomUUID();
database database
.prepare( .prepare(
@@ -287,6 +310,7 @@ function syncSectionTimer(
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)`
) )
.run(timerId, showId, section.id, section.explicit_duration_seconds, now); .run(timerId, showId, section.id, section.explicit_duration_seconds, now);
if (showTimerRunning(database, showId)) {
database database
.prepare( .prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc) `INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
@@ -294,6 +318,7 @@ function syncSectionTimer(
) )
.run(randomUUID(), timerId, now); .run(randomUUID(), timerId, now);
} }
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) { function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
usageBelongsToShow(database, showId, usageId); usageBelongsToShow(database, showId, usageId);
@@ -412,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
const state = ensureLiveState(database, showId, now); const state = ensureLiveState(database, showId, now);
const show = getShow(showId); const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND'); 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 durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({ const sections = show.sections.map((section) => ({
...section, ...section,
@@ -941,7 +969,8 @@ export function selectUsage(
(database, state, now) => { (database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED'); 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 === usageId) return;
if (state.current_usage_id) { if (state.current_usage_id) {
closeActiveRun(database, showId, now); 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( export function presentUsage(
showId: string, showId: string,
usageId: string | null, usageId: string | null,
+11 -1
View File
@@ -13,6 +13,7 @@ import {
getSharedMedia, getSharedMedia,
getSharedShow, getSharedShow,
revokeShare, revokeShare,
replaceShareToken,
updateShare updateShare
} from './shares.js'; } from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js'; import { addPieceUsage, addShowSection, createShow } from './shows.js';
@@ -138,8 +139,17 @@ describe('show shares', () => {
expect(getSharedShow(shared.token).status).toBe('password'); expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20'); const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized'); 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(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 () => { it('projects exactly the selected information scope', async () => {
+21
View File
@@ -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 { export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor); requireBarkeeperActor(actor);
const result = getDatabase() const result = getDatabase()
+22
View File
@@ -13,6 +13,7 @@ import {
duplicatePieceUsage, duplicatePieceUsage,
getPieceUsageContexts, getPieceUsageContexts,
getShow, getShow,
listPreparationUsages,
listShowTemplates, listShowTemplates,
movePieceUsage, movePieceUsage,
moveShowSection, moveShowSection,
@@ -66,6 +67,27 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
} }
describe('show builder data layer', () => { 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', () => { it('creates a fundus original and usage atomically and idempotently', () => {
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null }); const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, { const section = addShowSection(created.id, created.revision, {
+55
View File
@@ -21,6 +21,17 @@ export type ShowListItem = {
sectionCount: number; 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 = { export type ShowTemplateSection = {
id: string; id: string;
name: 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[] { export function listShowTemplates(): ShowTemplate[] {
const database = getDatabase(); const database = getDatabase();
const templates = database const templates = database
+1 -1
View File
@@ -51,7 +51,7 @@
</header> </header>
<div class="shared-rundown"> <div class="shared-rundown">
{#each data.share.sections as section (section)} {#each data.share.sections as section (section)}
<section class="quiet-panel shared-section"> <section class="shared-section">
<header> <header>
<div> <div>
{#if section.category}<small {#if section.category}<small
+67 -28
View File
@@ -77,14 +77,19 @@
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId : draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}` ? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
: '', : '',
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : [] images:
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? [{ id: selectedMedia.id, altText: selectedMedia.altText }]
: []
}); });
let presentationPreviewImage = $derived( let presentationPreviewImage = $derived(
selectedMedia selectedMedia && ['text', 'image'].includes(draft.presentationMode)
? draft.presentationMode === 'text' ? draft.presentationMode === 'text'
? '/media' ? '/media'
: `/media/${selectedMedia.id}` : `/media/${selectedMedia.id}`
: sourcePreviewImage : draft.presentationMode === 'link'
? sourcePreviewImage
: ''
); );
let pendingHref: string | null = null; let pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
@@ -116,7 +121,7 @@
if ( if (
control instanceof HTMLInputElement && control instanceof HTMLInputElement &&
control.name === 'presentationMode' && control.name === 'presentationMode' &&
control.value !== 'text' && ['link', 'video'].includes(control.value) &&
draft.presentationSourcePosition === null draft.presentationSourcePosition === null
) { ) {
return; return;
@@ -348,10 +353,11 @@
/></label /></label
> >
<label <label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort} >Vorbereitungsaufwand<select
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option name="preparationEffort"
value="medium">Mittel</option bind:value={draft.preparationEffort}
><option value="high">Hoch</option></select ><option value={null}>Nicht angegeben</option><option value="low">Gering</option
><option value="medium">Mittel</option><option value="high">Hoch</option></select
></label ></label
> >
<label <label
@@ -533,21 +539,7 @@
</div> </div>
<div class="piece-presentation-layout"> <div class="piece-presentation-layout">
<div class="piece-presentation-controls"> <div class="piece-presentation-controls">
<label <p class="field-label">Präsentationsart</p>
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentationText"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung"> <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 {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
><input ><input
@@ -558,9 +550,30 @@
/>{mode[1]}</label />{mode[1]}</label
>{/each} >{/each}
</div> </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="presentationText"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
rows={8}
bind:value={draft.presentationText}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentationText" value={draft.presentationText} />
{/if}
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<label <label
>Quelle<select >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
name="presentationSourcePosition" name="presentationSourcePosition"
bind:value={draft.presentationSourcePosition} bind:value={draft.presentationSourcePosition}
onchange={presentationSourceChanged} onchange={presentationSourceChanged}
@@ -571,7 +584,7 @@
></label ></label
> >
<label <label
>Kartentitel<input >{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle" name="presentationTitle"
maxlength="300" maxlength="300"
placeholder="Optionaler Anzeigetitel" placeholder="Optionaler Anzeigetitel"
@@ -579,16 +592,24 @@
oninput={() => (draft.presentationTitleManual = true)} oninput={() => (draft.presentationTitleManual = true)}
/></label /></label
> >
{#if draft.presentationMode === 'link'}
<label <label
>Bild-URL<input >Vorschaubild-URL<input
name="presentationImageUrl" name="presentationImageUrl"
type="url" type="url"
maxlength="2000" maxlength="2000"
placeholder="https://…" placeholder="Wird aus der Linkvorschau geladen"
bind:value={draft.presentationImageUrl} bind:value={draft.presentationImageUrl}
oninput={() => (draft.presentationImageManual = true)} oninput={() => (draft.presentationImageManual = true)}
/></label /></label
> >
{:else}
<input
type="hidden"
name="presentationImageUrl"
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field" <label class="wide-field"
>Anreißer<textarea >Anreißer<textarea
name="presentationDescription" name="presentationDescription"
@@ -612,6 +633,20 @@
> >
</div> </div>
</div> </div>
{:else}
<input
type="hidden"
name="presentationSourcePosition"
value={draft.presentationSourcePosition ?? ''}
/>
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
<input
type="hidden"
name="presentationDescription"
value={draft.presentationDescription}
/>
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div> </div>
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay <PresentationDisplay
@@ -646,7 +681,11 @@
</section> </section>
</form> </form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}> <section
class="piece-editor-panel piece-media-panel"
hidden={activeSection !== 'presentation' ||
!['text', 'image'].includes(draft.presentationMode)}
>
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Hochgeladene Bilder</p> <p class="eyebrow">Hochgeladene Bilder</p>
+53 -6
View File
@@ -16,6 +16,7 @@ import {
rotatePresentationChannelKey, rotatePresentationChannelKey,
resetSectionTimer, resetSectionTimer,
resetTotalTimer, resetTotalTimer,
resumeUsage,
resumeEndedLive, resumeEndedLive,
resumeLive, resumeLive,
selectUsage, selectUsage,
@@ -25,7 +26,13 @@ import {
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js'; import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) { function text(data: FormData, name: string) {
@@ -99,10 +106,16 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
try { try {
const share = await createShare(actor, { const share = await createShare(actor, {
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'), showId: text(data, 'showId'),
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}); });
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` }; return {
intent: 'createShare',
success: 'Leselink angelegt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem }); return fail(problem.status, { intent: 'createShare', ...problem });
@@ -112,16 +125,38 @@ export const actions = {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
try { try {
await updateShare(actor, text(data, 'shareId'), { await updateShare(
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'), actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}, text(data, 'showId')); },
text(data, 'showId')
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' }; return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) { } catch (cause) {
const problem = actionError(cause); const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem }); return fail(problem.status, { intent: 'updateShare', ...problem });
} }
}, },
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), text(data, 'showId'));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'replaceShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => { deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -253,6 +288,18 @@ export const actions = {
) )
); );
}, },
resumeUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeUsage', () =>
resumeUsage(
text(data, 'showId'),
text(data, 'usageId'),
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
present: async ({ locals, request }) => { present: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+43 -3
View File
@@ -591,7 +591,12 @@
</section> </section>
<section> <section>
<strong>Ausgabekanal</strong> <strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement shows={[{ ...data.live.show, startsAtUtc: null }]} shares={data.shares} fixedShowId={data.live.show.id} {form} />{/if} {#if data.canManageShares}<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}> <form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button> <button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
@@ -814,8 +819,13 @@
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if} </strong>{/if}
</div> </div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}> {#if viewedSection?.twitchTags.length}<button
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')} class="text-button live-twitch-tags"
type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if} </button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro"> {#if viewedUsage.introHtml}<section class="live-script-section live-intro">
@@ -1000,6 +1010,22 @@
> >
</button> </button>
<div class="live-run-actions"> <div class="live-run-actions">
{#if usage.status === 'done' || usage.status === 'skipped'}<form
method="POST"
action={liveAction('resumeUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="text-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}
>Wiederaufnehmen</button
>
</form>{/if}
{#if usage.status === 'open'}<details class="live-run-order"> {#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`} <summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary ><AppIcon name="ellipsis" /></summary
@@ -1176,6 +1202,19 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
{#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="primary-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
>
</form>
{:else}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -1208,6 +1247,7 @@
: 'Erledigen & Sendungsende'} : 'Erledigen & Sendungsende'}
</button> </button>
</form> </form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong {:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if} >{/if}
</div> </div>
+7 -3
View File
@@ -2,8 +2,12 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js'; import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js'; import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js'; import {
import { listPieces } from '#lib/server/content.js'; createShow,
listPreparationUsages,
listShows,
listShowTemplates
} from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
@@ -13,7 +17,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return { return {
shows: listShows(['draft', 'ready', 'live']), shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }), preparationUsages: listPreparationUsages(),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
selectedMonth, selectedMonth,
+38 -6
View File
@@ -53,6 +53,10 @@
}).format(new Date(`${value}T12:00:00Z`)); }).format(new Date(`${value}T12:00:00Z`));
} }
function preparationHref(showId: string, usageId: string) {
return `${resolve('/sendungen/[id]', { id: showId })}?usage=${encodeURIComponent(usageId)}#usage-${encodeURIComponent(usageId)}`;
}
function statusLabel(status: string) { function statusLabel(status: string) {
if (status === 'ready') return 'Bereit'; if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf'; if (status === 'draft') return 'Entwurf';
@@ -125,13 +129,41 @@
</form> </form>
</details> </details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title"> {#if data.preparationUsages.length}<section
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header> class="show-library preparation-overview"
aria-labelledby="preparation-title"
>
<header>
<h2 id="preparation-title">Noch vorzubereiten</h2>
<span>{data.preparationUsages.length}</span>
</header>
<div class="preparation-list"> <div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}> {#each data.preparationUsages as usage (usage.usageId)}
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span> <!-- The helper starts from a route resolved against the configured base path. -->
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if} <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
</a>{/each} <a href={preparationHref(usage.showId, usage.usageId)}>
<div class="preparation-copy">
<span
class="preparation-topics"
aria-label={usage.topics.map((topic) => topic.name).join(', ')}
>{#each usage.topics as topic (topic.name)}<AppIcon
name={topic.iconName}
/>{/each}</span
>
<span
><strong>{usage.title}</strong><small>{usage.showTitle} · {usage.sectionName}</small
></span
>
</div>
{#if usage.preparationEffort}<b class={`effort ${usage.preparationEffort}`}
>{usage.preparationEffort === 'low'
? 'Gering'
: usage.preparationEffort === 'medium'
? 'Mittel'
: 'Hoch'}</b
>{/if}
</a>
{/each}
</div> </div>
</section>{/if} </section>{/if}
+21 -1
View File
@@ -4,7 +4,13 @@ import type { Actions, PageServerLoad } from './$types';
import { requireBarkeeper, requireUser } from '#lib/server/auth.js'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js'; import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js'; import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
import { import {
addPieceUsage, addPieceUsage,
@@ -192,6 +198,20 @@ export const actions = {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) }); return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
} }
}, },
replaceShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, text(data, 'shareId'), params.id);
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'replaceShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => { deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+7 -1
View File
@@ -661,6 +661,7 @@
{#if section.usages.length}<div class="usage-list"> {#if section.usages.length}<div class="usage-list">
{#each section.usages as usage, usageIndex (usage.id)} {#each section.usages as usage, usageIndex (usage.id)}
<div <div
id={`usage-${usage.id}`}
class="usage-drag-slot" class="usage-drag-slot"
role="presentation" role="presentation"
ondragover={(event) => { ondragover={(event) => {
@@ -750,6 +751,7 @@
</form> </form>
<UsageEditorDialog <UsageEditorDialog
{usage} {usage}
open={page.url.searchParams.get('usage') === usage.id}
revision={data.show.revision} revision={data.show.revision}
sectionId={section.id} sectionId={section.id}
sections={data.show.sections} sections={data.show.sections}
@@ -893,7 +895,11 @@
placeholder="optional, Sekunden" placeholder="optional, Sekunden"
/></label /></label
><label ><label
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label >Twitch-Tags<input
name="twitchTags"
maxlength="300"
placeholder="Gaming, Deutsch"
/></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button> ><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form> </form>
+21 -1
View File
@@ -8,7 +8,13 @@ import {
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js'; import {
createShare,
deleteShare,
listShares,
replaceShareToken,
updateShare
} from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js'; import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js'; import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
@@ -118,6 +124,20 @@ export const actions = {
return fail(400, { intent: 'updateShare', error: actionError(cause) }); return fail(400, { intent: 'updateShare', error: actionError(cause) });
} }
}, },
replaceShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const token = replaceShareToken(actor, String(data.get('shareId') || ''));
return {
intent: 'replaceShare',
success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.',
shareUrl: `/freigabe/${token}`
};
} catch (cause) {
return fail(400, { intent: 'replaceShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => { deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+29 -4
View File
@@ -33,6 +33,7 @@
{form.success} {form.success}
</div>{/if} </div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} /> <ShareManagement shows={data.shows} shares={data.shares} {form} />
<h2 class="section-heading">Schnittstellen</h2>
<section class="quiet-panel integration-panel"> <section class="quiet-panel integration-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -45,6 +46,8 @@
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang. Eingang.
</p> </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"> <form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label> <label>Name<input name="name" required maxlength="120" /></label>
<label <label
@@ -79,6 +82,7 @@
> >
<button class="primary-button" type="submit">Feed anlegen</button> <button class="primary-button" type="submit">Feed anlegen</button>
</form> </form>
</details>
<div class="integration-list"> <div class="integration-list">
{#each data.feeds as feed (feed.id)} {#each data.feeds as feed (feed.id)}
<details> <details>
@@ -169,9 +173,16 @@
{#if form?.technicalToken}<div class="token-reveal" role="status"> {#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code> <strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if} </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"> <form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label <label
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label >Name<input
name="name"
required
maxlength="120"
placeholder="Redaktionsassistenz"
/></label
> >
<label <label
>Berechtigung<select name="permissions" >Berechtigung<select name="permissions"
@@ -182,6 +193,7 @@
> >
<button class="primary-button" type="submit">Zugang anlegen</button> <button class="primary-button" type="submit">Zugang anlegen</button>
</form> </form>
</details>
<div class="integration-list"> <div class="integration-list">
{#each data.technicalAccesses as access (access.id)} {#each data.technicalAccesses as access (access.id)}
<div class="access-row"> <div class="access-row">
@@ -456,6 +468,17 @@
gap: 0.8rem; gap: 0.8rem;
align-items: end; 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) { .integration-form label:not(.check-row) {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
@@ -472,8 +495,10 @@
} }
.integration-list details, .integration-list details,
.access-row { .access-row {
border-top: 1px solid var(--color-border); padding: 0.75rem 0.8rem;
padding-top: 0.8rem; border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
} }
.integration-list summary, .integration-list summary,
.access-row { .access-row {
@@ -488,7 +513,7 @@
gap: 0.2rem; gap: 0.2rem;
} }
.integration-list small { .integration-list small {
color: var(--color-text-muted); color: var(--text-muted);
} }
.row-actions { .row-actions {
display: flex; display: flex;
+10 -5
View File
@@ -1106,6 +1106,7 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover'); const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible(); await expect(popover).toBeVisible();
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
const popoverBox = await popover.boundingBox(); const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0); expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width); expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
@@ -1414,8 +1415,11 @@ test('live control persists state and exposes only explicit presentation data',
.locator('.live-run-item.current') .locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) .filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled(); await expect(
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled(); page.locator('.live-action-bar').getByRole('button', { name: 'Wiederaufnehmen', exact: true })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Erledigen/ })).toHaveCount(0);
await expect( await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).toBeVisible();
@@ -1786,6 +1790,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
expect(guestWrite.status()).toBe(403); expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung'); await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' }); const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible(); await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first(); const shareRow = showCard.locator('.share-row').first();
@@ -1854,7 +1859,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
); );
await openEditorialField(page, 'Präsentationstext'); await openEditorialField(page, 'Präsentationstext');
await page await page
.getByRole('textbox', { name: 'Öffentlicher Text', exact: true }) .getByRole('textbox', { name: 'Öffentlicher Vorspann', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'); .fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
@@ -2002,7 +2007,7 @@ test('a quickly captured video can be written and previewed before classificatio
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check(); await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup'); const previewPromise = page.waitForEvent('popup');
@@ -2028,7 +2033,7 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.goto('/fundus/piece-console'); await page.goto('/fundus/piece-console');
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });