Complete 0.7.1 fixes

This commit is contained in:
hnzio committed 2026-10-26 09:35:19 +01:00
1 parent a08b02c34b
commit 64f55a8b1f
26 files changed
+591 -149

No files matched your search

+8 -8
View File
@@ -4,19 +4,19 @@
| Bereich | Nachweis | Stand | | 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 | | 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 26.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 | | Mobiler Live-Ablauf und Metadaten (#3, #13) | mobile Live-Regie ohne horizontalen Überlauf; viewportgebundenes Sheet, Safe-Area-/Standalone-Basis und Fokus in Chromium | 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 | | Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 26.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 | | Vorbereitung nach Einplanung (#6, #15) | Migration und Servertest für konkrete Usage-Snapshots; Chromium öffnet die Usage und entfernt sie nach „sendefertig“ | erfüllt am 26.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 | | Kontextabhängige Präsentation (#8) | vier serverseitig getrennte Modi; Fundus-/Usage-Vorschauen sowie Link-, Video-, Text-/Bildausgabe in Chromium | erfüllt am 26.10.2026 |
| Gastansicht und Freigaben (#11, #12) | Gastansicht mobil/desktop, erneut geöffnete Dialogergebnisse und Tokenersatz mit Invalidierungstest | erfüllt am 25.10.2026 | | Gastansicht und Freigaben (#11, #12) | gestalteter Einzeldialog, Verwaltungsreihenfolge und Kartenpadding per Chromium; Tokenersatz mit Invalidierungstest | erfüllt am 26.10.2026 |
| Iconsuche (#14) | Chromium prüft sichtbares und unmittelbar fokussiertes Suchfeld bei 390, 960 und 1280 Pixeln | erfüllt am 25.10.2026 | | Iconsuche (#14) | Suchfokus innerhalb der öffnenden Bedienaktion; Chromium bei mobilen und breiten Ansichten | erfüllt am 26.10.2026 |
- `npm test`: 60 von 60 Verhaltenstests erfolgreich. - `npm test`: 60 von 60 Verhaltenstests erfolgreich.
- `npm run lint`: erfolgreich. - `npm run lint`: erfolgreich.
- `npm run check`: 0 Fehler und 0 Warnungen. - `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. - `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- `npm run test:e2e`: 18 von 18 Chromium-Abläufen erfolgreich. - `npm run test:e2e`: 19 von 19 Chromium-Abläufen erfolgreich.
- Safari auf echtem iOS-Gerät: offen. - Safari auf echtem iOS-Gerät: offen.
- Installierte Webapp auf echtem iOS-Gerät: separat offen. - Installierte Webapp auf echtem iOS-Gerät: separat offen.
+7 -6
View File
@@ -453,12 +453,13 @@ Präsentationsmedium.
## Wartungskorrekturen 0.7.1 ## Wartungskorrekturen 0.7.1
Die Vorbereitungsliste projiziert bestehende Daten statt einen zweiten Migration `0019` ergänzt `preparation_status_snapshot` und
Vorbereitungszustand einzuführen: Sie verbindet aktive Sendungen mit ihren `preparation_effort_snapshot` an `piece_usages` und übernimmt für vorhandene
Usage-Snapshots und berücksichtigt nur Originale im Zustand `preparing`. Einplanungen einmalig die damaligen Originalwerte. Danach ist ausschließlich die
Dadurch erscheinen Mehrfachplanungen als getrennte Sendungsfassungen, während konkrete Sendungsfassung maßgeblich. Einplanen, Duplizieren und bewusstes
ungeplante Fundus-Pieces ausgeschlossen bleiben. Der Link adressiert die Usage Aktualisieren aus dem Fundus kopieren beide Werte; der Usage-Editor ändert sie
direkt; Titel und Themenicons stammen aus dem stabilen Snapshot. unabhängig. Die Übersicht berücksichtigt vorbereitende Nicht-Reserve-Usages
aktiver Sendungen, priorisiert zuerst deren Termin und führt direkt zum Editor.
Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben Ein Freigabe-Ersatzlink rotiert ausschließlich den gehashten Token derselben
Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und Freigabe. Umfang, Passwort-Hash und Ablauf bleiben erhalten, der alte Token und
+14
View File
@@ -504,3 +504,17 @@ Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und Freigabe transaktional und löscht alle Gastsitzungen; Sichtumfang, Passwort und
Ablauf werden nicht stillschweigend verändert. Ablauf werden nicht stillschweigend verändert.
## D-032: Vorbereitung als Eigenschaft der konkreten Einplanung
Die erste 0.7.1-Umsetzung aus D-031 leitete den Vorbereitungszustand noch vom
veränderlichen Fundus-Original ab. Das widerspricht der bestehenden Kopierregel
für Sendungsfassungen und verhindert unterschiedliche Arbeitsstände bei
Mehrfachverwendung. D-032 ersetzt deshalb diesen Teil von D-031: Status und
Aufwand werden mit Migration `0019` additiv an `piece_usages` gespeichert.
Vorbereitende und sendefertige Originale dürfen eingeplant werden. Einplanung,
Duplikation und ausdrückliches Aktualisieren kopieren den Zustand, während der
Usage-Editor ihn danach unabhängig ändert. `piece_usages.status` bleibt davon
getrennt und beschreibt weiterhin ausschließlich den Livezustand `open`,
`current`, `done` oder `skipped`.
+15 -9
View File
@@ -2,26 +2,32 @@
## 0.7.1 - Bedienung und Live-Korrekturen ## 0.7.1 - Bedienung und Live-Korrekturen
Veröffentlicht am 25. Oktober 2026 Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder - Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1). Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare - Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13). Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
- „Noch vorzubereiten“ zeigt nur tatsächlich eingeplante unfertige Fassungen - „Noch vorzubereiten“ speichert Status und Aufwand an der konkreten Einplanung,
aktiver Sendungen, priorisiert sie und verlinkt sie mit ihren Themenicons direkt priorisiert aktive Sendungen nach Termin und verlinkt mit den Themenicons direkt
in den Usage-Editor (#6, #15). in den Usage-Editor. Vorbereitende Originale können regulär eingeplant werden;
Mehrfachverwendungen bleiben unabhängig (#6, #15).
- Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video - Fundus- und Usage-Editor unterscheiden Text, Nur Bild, Linkvorschau und Video
durch kontextsensitive Eingaben und passende Vorschauen (#8). durch kontextsensitive Eingaben und passende Vorschauen (#8).
- Gastabschnitte verzichten auf unnötige äußere Karten. Freigabedialoge zeigen - Gast- und historische Abschnitte besitzen klar gepolsterte äußere Glaskarten.
den einmaligen Link nach dem Anlegen sofort wieder an, bieten Kopieren und Die Freigabeverwaltung liegt direkt vor dem Werkzeugkatalog; der gemeinsame
können einen alten Token sicher durch einen neuen ersetzen (#11, #12). Dialog und seine Formfelder folgen ohne nativen Doppelrahmen dem Sendewerk-
Design. Einmalige Links lassen sich kopieren und sicher ersetzen (#11, #12).
- Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14). - Die Iconsuche ist nach dem Öffnen sofort sichtbar und fokussiert (#14).
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte - RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog. Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Es gibt keine - Mobiles Live-Sheet und Backdrop sind am Viewport verankert. Viewport-Fit,
neue Datenbankmigration. 60 Verhaltenstests und 18 Chromium-Abläufe sind Standalone-Metadaten und Web-App-Manifest aktivieren iOS-Safe-Areas; ein echter
Safari-/Home-Bildschirm-Test bleibt mangels Gerät offen (#3, #13).
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Migration
`0019_usage_preparation.sql` ergänzt additive Usage-Snapshots. 60
Verhaltenstests und 19 Chromium-Abläufe sind
erfolgreich; echtes Safari und die installierte Webapp auf iOS bleiben offen. 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
+16 -11
View File
@@ -13,24 +13,29 @@ dokumentiert.
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen - Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten. wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, und lange - Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, lange
Metadaten verbreitern die Regie nicht mehr. Sendungskarten stapeln ihre Inhalte Metadaten verbreitern die Regie nicht mehr und das Sheet ist am mobilen
auf kleinen Bildschirmen eindeutig. Viewport verankert. `viewport-fit=cover`, Standalone-Metadaten und Manifest
- Die Vorbereitung zeigt nur konkrete Einplanungen aktiver Sendungen, deren aktivieren Safe Areas auch beim Start vom Home-Bildschirm. Sendungskarten
Fundus-Original noch in Vorbereitung ist. Usage-Titel, Sendung, Abschnitt, stapeln ihre Inhalte auf kleinen Bildschirmen eindeutig.
Priorität und gespeicherte Themenicons führen direkt zur Sendungsfassung. - Die Vorbereitung liegt als eigener Status- und Aufwandsnapshot an der konkreten
Einplanung. Vorbereitende Fundus-Pieces lassen sich planen; Originaländerungen,
Duplikation und bewusste Aktualisierung folgen der bestehenden Snapshotregel.
Usage-Titel, Sendung, Abschnitt, Termin und Themenicons führen direkt in den
Usage-Editor; „sendefertig“ entfernt die Fassung aus der Übersicht.
- Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor - Text, Nur Bild, Linkvorschau und Video besitzen in Fundus- und Usage-Editor
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
Moduswechsel erhalten. Moduswechsel erhalten.
- Die Iconsuche fokussiert beim Öffnen sofort das Suchfeld. RSS-Feeds und - Die Iconsuche fokussiert innerhalb der öffnenden Klick-/Tastaturaktion das
technische Zugänge folgen in der Verwaltung dem kompakten Katalogmuster. Suchfeld. RSS-Feeds und technische Zugänge verwenden gestaltete Formfelder.
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren - Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
alten Token und dessen Gastsitzungen. Die Gastansicht trennt Abschnitte ohne alten Token und dessen Gastsitzungen. Die Verwaltung zeigt Freigaben direkt
zusätzliche äußere Karten. vor dem Werkzeugkatalog; der Dialog besitzt nur eine gestaltete Glasfläche.
Gast- und historische Abschnitte besitzen äußere Karten mit Innenabstand.
Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und Geprüft wurden 60 von 60 Verhaltenstests, Lint, Typprüfung, Produktionsbuild und
18 von 18 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren, 19 von 19 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
echten iOS-Gerät bleiben mangels Gerät getrennt offen. echten iOS-Gerät bleiben mangels Gerät getrennt offen.
+17
View File
@@ -0,0 +1,17 @@
ALTER TABLE piece_usages ADD COLUMN preparation_status_snapshot TEXT NOT NULL DEFAULT 'ready'
CHECK (preparation_status_snapshot IN ('preparing', 'ready'));
ALTER TABLE piece_usages ADD COLUMN preparation_effort_snapshot TEXT
CHECK (preparation_effort_snapshot IS NULL OR preparation_effort_snapshot IN ('low', 'medium', 'high'));
UPDATE piece_usages
SET preparation_status_snapshot = CASE
WHEN EXISTS (
SELECT 1 FROM pieces p
WHERE p.id = piece_usages.original_piece_id AND p.status = 'preparing'
) THEN 'preparing'
ELSE 'ready'
END,
preparation_effort_snapshot = (
SELECT p.preparation_effort FROM pieces p WHERE p.id = piece_usages.original_piece_id
);
+73 -5
View File
@@ -113,10 +113,51 @@
} }
.share-management { .share-management {
margin-bottom: 1.5rem; max-height: min(82dvh, 54rem);
padding: clamp(1rem, 2.5vw, 1.4rem);
overflow-y: auto;
}
.share-management-dialog {
width: min(64rem, calc(100% - 2rem));
max-width: none;
max-height: none;
margin: auto;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-large);
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
box-shadow: var(--shadow);
color: var(--text);
overflow: hidden;
backdrop-filter: blur(24px) saturate(1.12);
}
.share-management-dialog::backdrop {
background: rgb(3 8 16 / 58%);
backdrop-filter: blur(5px);
}
.share-catalog-panel {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding: 1rem; padding: 1rem;
} }
.share-catalog-panel .panel-heading {
margin: 0;
}
.share-catalog-panel h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.share-catalog-panel .catalog-note {
margin: 0;
}
.share-create-form { .share-create-form {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
@@ -133,6 +174,28 @@
font-weight: 700; font-weight: 700;
} }
.share-create-form :is(input, select),
.share-edit-form :is(input, select),
.integration-form :is(input, select) {
width: 100%;
min-width: 0;
min-height: 2.65rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--field);
color: var(--text);
font: inherit;
}
.share-create-form :is(input, select):focus-visible,
.share-edit-form :is(input, select):focus-visible,
.integration-form :is(input, select):focus-visible {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 30%, transparent);
outline-offset: 1px;
}
.share-create-form button { .share-create-form button {
align-self: end; align-self: end;
} }
@@ -302,8 +365,11 @@
} }
.shared-section { .shared-section {
padding: 0 0 1.25rem; padding: clamp(1rem, 2.5vw, 1.35rem);
border-bottom: 1px solid var(--line); }
.historical-section {
padding: clamp(1rem, 2.5vw, 1.35rem);
} }
.shared-section > header, .shared-section > header,
@@ -1328,6 +1394,7 @@ textarea:focus {
} }
.live-controller-shell { .live-controller-shell {
height: 100vh;
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
margin-left: 0 !important; margin-left: 0 !important;
@@ -1340,6 +1407,7 @@ textarea:focus {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
width: 100%; width: 100%;
height: 100vh;
height: 100dvh; height: 100dvh;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0.55rem; gap: 0.55rem;
@@ -2405,7 +2473,7 @@ textarea:focus {
.live-page.without-rail .live-rail, .live-page.without-rail .live-rail,
.live-rail { .live-rail {
display: grid; display: grid;
position: absolute; position: fixed;
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;
@@ -2429,7 +2497,7 @@ textarea:focus {
.live-rail-backdrop.visible { .live-rail-backdrop.visible {
display: block; display: block;
position: absolute; position: fixed;
z-index: 70; z-index: 70;
inset: 0; inset: 0;
border: 0; border: 0;
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="de" data-theme="light" data-theme-preference="system"> <html lang="de" data-theme="light" data-theme-preference="system">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f4f1eb" /> <meta name="theme-color" content="#f4f1eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Sendewerk" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<script> <script>
(function () { (function () {
try { try {
+8 -1
View File
@@ -15,6 +15,13 @@
) )
); );
let visible = $derived(filtered.slice(0, 120)); let visible = $derived(filtered.slice(0, 120));
function openAndFocus(event: MouseEvent) {
if (menu.open) return;
event.preventDefault();
menu.open = true;
searchInput.focus({ preventScroll: true });
}
</script> </script>
<div class="icon-picker"> <div class="icon-picker">
@@ -25,7 +32,7 @@
if (menu.open) queueMicrotask(() => searchInput.focus()); if (menu.open) queueMicrotask(() => searchInput.focus());
}} }}
> >
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary> <summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover"> <div class="icon-picker-popover">
<label <label
><span class="sr-only">Icons durchsuchen</span><input ><span class="sr-only">Icons durchsuchen</span><input
@@ -62,7 +62,6 @@
</article> </article>
{:else if content.mode === 'image'} {:else if content.mode === 'image'}
<article class="output-content output-image-only"> <article class="output-content output-image-only">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if} {#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article> </article>
{:else if content.mode === 'link'} {:else if content.mode === 'link'}
+29 -8
View File
@@ -27,11 +27,13 @@
shows, shows,
shares, shares,
fixedShowId = '', fixedShowId = '',
catalogPanel = false,
form = null form = null
}: { }: {
shows: ShareShow[]; shows: ShareShow[];
shares: ShareItem[]; shares: ShareItem[];
fixedShowId?: string; fixedShowId?: string;
catalogPanel?: boolean;
form?: { form?: {
intent?: string; intent?: string;
error?: string; error?: string;
@@ -104,18 +106,37 @@
} }
</script> </script>
<button {#if catalogPanel}
<section class="quiet-panel share-catalog-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2>Lesefreigaben</h2>
</div>
<button
class="compact-add-button share-dialog-trigger"
type="button"
aria-label="Lesefreigaben"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Freigaben verwalten
</button>
</div>
<p class="catalog-note">
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
</p>
</section>
{:else}
<button
class="secondary-button share-dialog-trigger" class="secondary-button share-dialog-trigger"
type="button" type="button"
onclick={() => dialog.showModal()} 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'}`}
> >
<AppIcon name="link" /> Lesefreigaben
</button>
{/if}
<dialog bind:this={dialog} class="share-management-dialog">
<section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">Gastzugänge</p> <p class="eyebrow">Gastzugänge</p>
+77 -33
View File
@@ -5,6 +5,7 @@
import InlineTitleEditor from './InlineTitleEditor.svelte'; import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
import PresentationDisplay from './PresentationDisplay.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
let { let {
@@ -32,6 +33,11 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
); );
let dirty = $state(false); let dirty = $state(false);
let selectedSourceId = $state(
untrack(
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
)
);
onMount(() => { onMount(() => {
if (open) editor.showModal(); if (open) editor.showModal();
}); });
@@ -43,6 +49,16 @@
return Number((seconds / 60).toFixed(2)); return Number((seconds / 60).toFixed(2));
} }
function previewHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('\n', '<br>');
}
function requestClose() { function requestClose() {
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return; if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
dirty = false; dirty = false;
@@ -50,14 +66,46 @@
} }
function presentationSourceChanged(event: Event) { function presentationSourceChanged(event: Event) {
const index = Number((event.currentTarget as HTMLSelectElement).value); selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
const source = draft.sources[index]; const source = draft.sources.find((item) => item.id === selectedSourceId);
if (!source) return; if (!source) return;
draft.presentationSourceLabel = source.label; draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url; draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds; draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
} }
let previewContent = $derived({
mode: draft.presentationMode,
topics: draft.topics.map((topic) => topic.name),
title: draft.outputTitle || draft.title,
html: previewHtml(draft.presentation),
source: draft.presentationSourceUrl
? {
label: draft.presentationSourceLabel,
url: draft.presentationSourceUrl,
timecodeSeconds: draft.presentationSourceTimecodeSeconds
}
: null,
previewTitle: draft.presentationTitle,
description: draft.presentationDescription,
imageUrl: draft.presentationMode === 'link' ? draft.presentationImageUrl : '',
siteName: draft.presentationSiteName,
provider: draft.presentationProvider,
providerId: draft.presentationProviderId,
embedUrl: '',
sources: [],
images: selectedMedia
? [{ id: selectedMedia.originalMediaId, altText: selectedMedia.altText }]
: []
});
let previewImageSrc = $derived(
selectedMedia && draft.presentationMode === 'text'
? '/media'
: selectedMedia && draft.presentationMode === 'image'
? `/media/${selectedMedia.originalMediaId}`
: ''
);
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) => {
@@ -139,6 +187,26 @@
<details class="piece-meta-details"> <details class="piece-meta-details">
<summary>Dauer und Einplanung</summary> <summary>Dauer und Einplanung</summary>
<div class="piece-meta-grid"> <div class="piece-meta-grid">
<label
>Vorbereitungsstatus<select
name="preparationStatus"
bind:value={draft.preparationStatus}
>
<option value="preparing">In Vorbereitung</option>
<option value="ready">Sendefertig</option>
</select></label
>
<label
>Vorbereitungsaufwand<select
name="preparationEffort"
bind:value={draft.preparationEffort}
>
<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
>Dauer in Minuten<input >Dauer in Minuten<input
name="durationMinutes" name="durationMinutes"
@@ -275,12 +343,12 @@
<div class="presentation-settings"> <div class="presentation-settings">
<label class="wide-field" <label class="wide-field"
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select >{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
bind:value={selectedSourceId}
onchange={presentationSourceChanged} onchange={presentationSourceChanged}
> >
{#each draft.sources as source, index (source.id)} <option value="">Quelle auswählen</option>
<option value={index} selected={source.url === draft.presentationSourceUrl} {#each draft.sources as source (source.id)}
>{source.label || source.url}</option <option value={source.id}>{source.label || source.url}</option>
>
{/each} {/each}
</select></label </select></label
> >
@@ -332,7 +400,7 @@
class="usage-media-picker" class="usage-media-picker"
> >
<legend>Hochgeladenes Präsentationsbild</legend> <legend>Hochgeladenes Präsentationsbild</legend>
<label {#if draft.presentationMode === 'text'}<label
><input ><input
type="radio" type="radio"
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
@@ -341,7 +409,7 @@
dirty = true; dirty = true;
}} }}
/> Kein Bild</label /> Kein Bild</label
> >{/if}
<div class="media-grid"> <div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice" {#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input ><input
@@ -358,31 +426,7 @@
</div> </div>
</fieldset>{/if} </fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
<h4>{draft.outputTitle || draft.title}</h4>
{#if draft.presentationMode === 'text'}
<p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'}
</p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else if draft.presentationMode === 'image'}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/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> </aside>
</div> </div>
</section> </section>
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 18, migrations: 19,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+34
View File
@@ -286,6 +286,40 @@ describe('live mode', () => {
expect(media).toBeNull(); expect(media).toBeNull();
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull(); expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
getDatabase()
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('presentation-snapshot', 'usage-console', 'presentation-image',
'Konsole auf dem Tisch', 0, 1)`
)
.run();
getDatabase()
.prepare(
`UPDATE piece_usages SET presentation_mode_snapshot = 'text',
presentation_image_only_snapshot = 1,
presentation_image_url_snapshot = 'https://private.example.test/not-for-image-mode'
WHERE id = 'usage-console'`
)
.run();
presentUsage(
'show-october',
'usage-console',
'present-image-only',
getLiveView('show-october').liveRevision
);
expect(getPresentationView(token)?.content).toMatchObject({
mode: 'image',
imageUrl: '',
images: [{ id: 'presentation-snapshot', altText: 'Konsole auf dem Tisch' }],
sources: []
});
expect(await getPresentationMedia(token, 'presentation-snapshot')).toMatchObject({
mimeType: 'image/png',
byteSize: 10,
altText: 'Konsole auf dem Tisch'
});
const replacementToken = issuePresentationAccess('show-october'); const replacementToken = issuePresentationAccess('show-october');
expect(replacementToken).toBe(token); expect(replacementToken).toBe(token);
expect(getPresentationView(token)?.content?.title).toBe( expect(getPresentationView(token)?.content?.title).toBe(
+9 -5
View File
@@ -1224,10 +1224,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
} }
| undefined; | undefined;
if (!usage) return { revision: state.presentation_revision, content: null }; if (!usage) return { revision: state.presentation_revision, content: null };
const mode = usage.presentation_mode_snapshot;
return { return {
revision: state.presentation_revision, revision: state.presentation_revision,
content: { content: {
mode: usage.presentation_mode_snapshot, mode,
topics: ( topics: (
database database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') .prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
@@ -1244,7 +1245,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
: null, : null,
previewTitle: usage.presentation_title_meta_snapshot, previewTitle: usage.presentation_title_meta_snapshot,
description: usage.presentation_description_snapshot, description: usage.presentation_description_snapshot,
imageUrl: usage.presentation_image_url_snapshot, imageUrl: mode === 'link' ? usage.presentation_image_url_snapshot : '',
siteName: usage.presentation_site_name_snapshot, siteName: usage.presentation_site_name_snapshot,
provider: usage.presentation_provider_snapshot, provider: usage.presentation_provider_snapshot,
providerId: usage.presentation_provider_id_snapshot, providerId: usage.presentation_provider_id_snapshot,
@@ -1254,7 +1255,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources: [], sources: [],
images: ( images: ['text', 'image'].includes(mode)
? (
database database
.prepare( .prepare(
`SELECT id, alt_text FROM usage_media_snapshots `SELECT id, alt_text FROM usage_media_snapshots
@@ -1262,13 +1264,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
) )
.all(state.presentation_usage_id) as { id: string; alt_text: string }[] .all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text })) ).map((media) => ({ id: media.id, altText: media.alt_text }))
: []
} }
}; };
} }
export async function getPresentationPreviewImage(token: string) { export async function getPresentationPreviewImage(token: string) {
const view = getPresentationView(token); const view = getPresentationView(token);
if (!view?.content?.imageUrl || view.content.mode === 'text') return null; if (!view?.content?.imageUrl || view.content.mode !== 'link') return null;
try { try {
return await fetchPresentationImage(view.content.imageUrl); return await fetchPresentationImage(view.content.imageUrl);
} catch { } catch {
@@ -1292,7 +1295,8 @@ export async function getPresentationMedia(
JOIN show_sections s ON s.id = u.show_section_id JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1` AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1
AND (u.presentation_image_only_snapshot = 1 OR u.presentation_mode_snapshot = 'text')`
) )
.get(showId, snapshotId) as .get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
+29 -5
View File
@@ -61,31 +61,55 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationImageUrl: edited.presentationImageUrl, presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName, presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId, presentationMediaId: edited.presentationMediaId,
preparationStatus: edited.preparationStatus,
preparationEffort: edited.preparationEffort,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds durationSeconds: edited.durationSeconds
}; };
} }
describe('show builder data layer', () => { describe('show builder data layer', () => {
it('lists only scheduled preparing originals as concrete usage snapshots', () => { it('tracks preparation on the concrete usage independently from its original', () => {
const database = getDatabase(); const database = getDatabase();
database database
.prepare( .prepare(
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'" "UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
) )
.run(); .run();
const usages = listPreparationUsages(); database.prepare("UPDATE pieces SET status = 'preparing' WHERE id = 'piece-sounds'").run();
const show = getShow('show-october')!;
const planned = addPieceUsage(show.id, show.revision, {
pieceId: 'piece-console',
sectionId: show.sections[0].id
});
expect(usages.map((usage) => usage.usageId)).toEqual(['usage-games-console', 'usage-console']); database
expect(usages[1]).toMatchObject({ .prepare(
"UPDATE pieces SET status = 'ready', preparation_effort = 'low' WHERE id = 'piece-console'"
)
.run();
const usages = listPreparationUsages();
expect(usages.map((usage) => usage.usageId)).toEqual([planned.id]);
expect(usages[0]).toMatchObject({
showId: 'show-october', showId: 'show-october',
showTitle: 'Herbstsendung: Kleine Welten', showTitle: 'Herbstsendung: Kleine Welten',
sectionName: 'Kleine Welten', sectionName: show.sections[0].name,
title: 'Die kleine Konsole mit dem großen Dreh', title: 'Die kleine Konsole mit dem großen Dreh',
preparationEffort: 'high', preparationEffort: 'high',
topics: [{ name: 'Gaming' }] topics: [{ name: 'Gaming' }]
}); });
expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false); expect(usages.some((usage) => usage.title === 'Drei Sounds, ein Spiel')).toBe(false);
const plannedUsage = getShow(show.id)!.sections[0].usages.find(
(usage) => usage.id === planned.id
)!;
updatePieceUsage(
show.id,
planned.id,
planned.revision,
editedUsage(plannedUsage, { preparationStatus: 'ready' })
);
expect(listPreparationUsages()).toEqual([]);
}); });
it('creates a fundus original and usage atomically and idempotently', () => { it('creates a fundus original and usage atomically and idempotently', () => {
+123 -33
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { createPiece } from './content.js'; import { createPiece } from './content.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { detectVideoProvider, type PresentationMode } from './presentation.js'; import { detectVideoProvider, type PresentationMode, type VideoProvider } from './presentation.js';
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const; export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const; export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
@@ -10,6 +10,8 @@ export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
export type ShowStatus = (typeof SHOW_STATUSES)[number]; export type ShowStatus = (typeof SHOW_STATUSES)[number];
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>; export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
export type UsageStatus = (typeof USAGE_STATUSES)[number]; export type UsageStatus = (typeof USAGE_STATUSES)[number];
export type UsagePreparationStatus = 'preparing' | 'ready';
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type ShowListItem = { export type ShowListItem = {
id: string; id: string;
@@ -28,7 +30,7 @@ export type PreparationUsage = {
showStartsAtUtc: string | null; showStartsAtUtc: string | null;
sectionName: string; sectionName: string;
title: string; title: string;
preparationEffort: 'low' | 'medium' | 'high' | null; preparationEffort: PreparationEffort;
topics: UsageTopic[]; topics: UsageTopic[];
}; };
@@ -53,6 +55,8 @@ export type PieceUsage = {
originalPieceId: string | null; originalPieceId: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparationStatus: UsagePreparationStatus;
preparationEffort: PreparationEffort;
durationSeconds: number; durationSeconds: number;
title: string; title: string;
outputTitle: string; outputTitle: string;
@@ -70,7 +74,7 @@ export type PieceUsage = {
presentationDescription: string; presentationDescription: string;
presentationImageUrl: string; presentationImageUrl: string;
presentationSiteName: string; presentationSiteName: string;
presentationProvider: string; presentationProvider: VideoProvider | '';
presentationProviderId: string; presentationProviderId: string;
ownNotes: string; ownNotes: string;
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[]; sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
@@ -186,6 +190,8 @@ export type PieceUsageUpdate = {
presentationSiteName: string; presentationSiteName: string;
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
preparationStatus?: UsagePreparationStatus;
preparationEffort?: PreparationEffort;
tools?: { id: string; workUrl: string; note: string }[]; tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null; presentationMediaId?: string | null;
}; };
@@ -231,6 +237,8 @@ type UsageRow = {
original_piece_id: string | null; original_piece_id: string | null;
position: number; position: number;
status: UsageStatus; status: UsageStatus;
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
duration_seconds: number; duration_seconds: number;
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string; output_title_snapshot: string;
@@ -249,7 +257,7 @@ type UsageRow = {
presentation_description_snapshot: string; presentation_description_snapshot: string;
presentation_image_url_snapshot: string; presentation_image_url_snapshot: string;
presentation_site_name_snapshot: string; presentation_site_name_snapshot: string;
presentation_provider_snapshot: string; presentation_provider_snapshot: VideoProvider | '';
presentation_provider_id_snapshot: string; presentation_provider_id_snapshot: string;
own_notes: string; own_notes: string;
}; };
@@ -470,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
originalPieceId: row.original_piece_id, originalPieceId: row.original_piece_id,
position: row.position, position: row.position,
status: row.status, status: row.status,
preparationStatus: row.preparation_status_snapshot,
preparationEffort: row.preparation_effort_snapshot,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
title: row.title_snapshot, title: row.title_snapshot,
outputTitle: row.output_title_snapshot, outputTitle: row.output_title_snapshot,
@@ -786,16 +796,16 @@ export function listPreparationUsages(): PreparationUsage[] {
.prepare( .prepare(
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title, `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, sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
u.title_snapshot, p.preparation_effort u.title_snapshot, u.preparation_effort_snapshot
FROM piece_usages u 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
JOIN shows sh ON sh.id = s.show_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') WHERE sh.status IN ('draft', 'ready', 'live')
AND s.is_reserve_bank = 0 AND p.deleted_at_utc IS NULL AND p.status = 'preparing' AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
ORDER BY CASE p.preparation_effort ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc,
CASE u.preparation_effort_snapshot
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END, 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` s.position, u.position`
) )
.all() as { .all() as {
usage_id: string; usage_id: string;
@@ -804,7 +814,7 @@ export function listPreparationUsages(): PreparationUsage[] {
show_starts_at_utc: string | null; show_starts_at_utc: string | null;
section_name: string; section_name: string;
title_snapshot: string; title_snapshot: string;
preparation_effort: PreparationUsage['preparationEffort']; preparation_effort_snapshot: PreparationUsage['preparationEffort'];
}[]; }[];
const topics = database.prepare( const topics = database.prepare(
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position' 'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
@@ -816,7 +826,7 @@ export function listPreparationUsages(): PreparationUsage[] {
showStartsAtUtc: row.show_starts_at_utc, showStartsAtUtc: row.show_starts_at_utc,
sectionName: row.section_name, sectionName: row.section_name,
title: row.title_snapshot, title: row.title_snapshot,
preparationEffort: row.preparation_effort, preparationEffort: row.preparation_effort_snapshot,
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({ topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
name: topic.name, name: topic.name,
iconName: topic.icon_name iconName: topic.icon_name
@@ -876,6 +886,7 @@ export function getShow(id: string): Show | null {
const usageRows = database const usageRows = database
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.preparation_status_snapshot, u.preparation_effort_snapshot,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot, u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot, CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
@@ -1138,8 +1149,9 @@ export function duplicateCompletedShow(
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
for (const section of sections) { for (const section of sections) {
const sectionId = randomUUID(); const sectionId = randomUUID();
@@ -1182,6 +1194,8 @@ export function duplicateCompletedShow(
usage.presentation_provider_snapshot, usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot, usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot, usage.presentation_image_only_snapshot,
usage.preparation_status_snapshot,
usage.preparation_effort_snapshot,
usage.own_notes, usage.own_notes,
usage.is_reserve usage.is_reserve
); );
@@ -1361,7 +1375,8 @@ export function addPieceUsage(
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.preparation_effort
FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id FROM pieces p LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position WHERE p.id = ?` AND ps.position = p.presentation_source_position WHERE p.id = ?`
) )
@@ -1387,6 +1402,7 @@ export function addPieceUsage(
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string; status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!piece) throw new Error('NOT_FOUND'); if (!piece) throw new Error('NOT_FOUND');
@@ -1397,7 +1413,7 @@ export function addPieceUsage(
) { ) {
throw new Error('NOT_FOUND'); throw new Error('NOT_FOUND');
} }
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY'); if (!['preparing', 'ready'].includes(piece.status)) throw new Error('PIECE_NOT_READY');
const ids = ( const ids = (
database database
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
@@ -1414,8 +1430,9 @@ export function addPieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -1442,6 +1459,8 @@ export function addPieceUsage(
piece.presentation_site_name, piece.presentation_site_name,
piece.presentation_provider, piece.presentation_provider,
piece.presentation_provider_id, piece.presentation_provider_id,
piece.status,
piece.preparation_effort,
input.sectionId === null ? 1 : 0 input.sectionId === null ? 1 : 0
); );
snapshotPieceRelations(database, id, input.pieceId); snapshotPieceRelations(database, id, input.pieceId);
@@ -1523,6 +1542,7 @@ export function duplicatePieceUsage(
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot,
preparation_status_snapshot, preparation_effort_snapshot,
own_notes, is_reserve FROM piece_usages WHERE id = ?` own_notes, is_reserve FROM piece_usages WHERE id = ?`
) )
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & { .get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
@@ -1545,8 +1565,9 @@ export function duplicatePieceUsage(
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot,
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -1573,6 +1594,8 @@ export function duplicatePieceUsage(
source.presentation_site_name_snapshot, source.presentation_site_name_snapshot,
source.presentation_provider_snapshot, source.presentation_provider_snapshot,
source.presentation_provider_id_snapshot, source.presentation_provider_id_snapshot,
source.preparation_status_snapshot,
source.preparation_effort_snapshot,
source.own_notes, source.own_notes,
source.is_reserve source.is_reserve
); );
@@ -1601,7 +1624,8 @@ export function refreshPieceUsageFromOriginal(
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
p.presentation_title, p.presentation_description, p.presentation_image_url, p.presentation_title, p.presentation_description, p.presentation_image_url,
p.presentation_site_name, p.presentation_provider, p.presentation_provider_id p.presentation_site_name, p.presentation_provider, p.presentation_provider_id,
p.status, p.preparation_effort
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position AND ps.position = p.presentation_source_position
@@ -1628,9 +1652,12 @@ export function refreshPieceUsageFromOriginal(
presentation_site_name: string; presentation_site_name: string;
presentation_provider: string; presentation_provider: string;
presentation_provider_id: string; presentation_provider_id: string;
status: string;
preparation_effort: PreparationEffort;
} }
| undefined; | undefined;
if (!source) throw new Error('ORIGINAL_NOT_FOUND'); if (!source) throw new Error('ORIGINAL_NOT_FOUND');
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
@@ -1640,7 +1667,8 @@ export function refreshPieceUsageFromOriginal(
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?, presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?, presentation_site_name_snapshot = ?, presentation_provider_snapshot = ?,
presentation_provider_id_snapshot = ? WHERE id = ?` presentation_provider_id_snapshot = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
) )
.run( .run(
source.title, source.title,
@@ -1663,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
source.presentation_site_name, source.presentation_site_name,
source.presentation_provider, source.presentation_provider,
source.presentation_provider_id, source.presentation_provider_id,
source.status,
source.preparation_effort,
usageId usageId
); );
snapshotPieceRelations( snapshotPieceRelations(
@@ -1687,6 +1717,18 @@ export function updatePieceUsage(
const title = cleanText(input.title, 200, 'title', true); const title = cleanText(input.title, 200, 'title', true);
const outputTitle = cleanText(input.outputTitle, 300, 'output_title'); const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
const duration = validDuration(input.durationSeconds)!; const duration = validDuration(input.durationSeconds)!;
if (
input.preparationStatus !== undefined &&
!['preparing', 'ready'].includes(input.preparationStatus)
) {
throw new Error('INVALID_PREPARATION_STATUS');
}
if (
input.preparationEffort !== undefined &&
![null, 'low', 'medium', 'high'].includes(input.preparationEffort)
) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
const values = { const values = {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
@@ -1697,7 +1739,7 @@ export function updatePieceUsage(
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
}; };
const presentation = validPresentation( const submittedPresentation = validPresentation(
input.presentationMode, input.presentationMode,
input.presentationSourceLabel, input.presentationSourceLabel,
input.presentationSourceUrl, input.presentationSourceUrl,
@@ -1711,6 +1753,57 @@ export function updatePieceUsage(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
usageSection(database, showId, usageId); usageSection(database, showId, usageId);
const currentPreparation = database
.prepare(
`SELECT preparation_status_snapshot, preparation_effort_snapshot
FROM piece_usages WHERE id = ?`
)
.get(usageId) as {
preparation_status_snapshot: UsagePreparationStatus;
preparation_effort_snapshot: PreparationEffort;
};
let presentation = submittedPresentation;
if (presentation.mode === 'link' || presentation.mode === 'video') {
const source = database
.prepare(
`SELECT label, url, timecode_seconds FROM usage_source_snapshots
WHERE usage_id = ? AND url = ?`
)
.get(usageId, presentation.sourceUrl) as
{ label: string; url: string; timecode_seconds: number | null } | undefined;
if (!source) throw new Error('INVALID_PRESENTATION');
presentation = validPresentation(
presentation.mode,
source.label,
source.url,
source.timecode_seconds,
presentation.title,
presentation.description,
presentation.imageUrl,
presentation.siteName
);
}
const selectedMediaId =
input.presentationMediaId === undefined
? ((
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(usageId) as { id: string } | undefined
)?.id ?? null)
: input.presentationMediaId;
if (
selectedMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(selectedMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
if (presentation.mode === 'image' && !selectedMediaId) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
@@ -1720,7 +1813,8 @@ export function updatePieceUsage(
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?, presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?, presentation_provider_snapshot = ?, presentation_provider_id_snapshot = ?,
own_notes = ?, duration_seconds = ? WHERE id = ?` own_notes = ?, duration_seconds = ?, preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
) )
.run( .run(
title, title,
@@ -1744,6 +1838,10 @@ export function updatePieceUsage(
presentation.providerId, presentation.providerId,
values.ownNotes, values.ownNotes,
duration, duration,
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
input.preparationEffort === undefined
? currentPreparation.preparation_effort_snapshot
: input.preparationEffort,
usageId usageId
); );
if (input.tools) { if (input.tools) {
@@ -1776,23 +1874,15 @@ export function updatePieceUsage(
} }
} }
if (input.presentationMediaId !== undefined) { if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?') .prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId); .run(usageId);
if (input.presentationMediaId !== null) { if (selectedMediaId !== null) {
database database
.prepare( .prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?' 'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
) )
.run(input.presentationMediaId, usageId); .run(selectedMediaId, usageId);
} }
} }
return result; return result;
+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="shared-section"> <section class="quiet-panel shared-section">
<header> <header>
<div> <div>
{#if section.category}<small {#if section.category}<small
+7 -10
View File
@@ -121,8 +121,8 @@
if ( if (
control instanceof HTMLInputElement && control instanceof HTMLInputElement &&
control.name === 'presentationMode' && control.name === 'presentationMode' &&
['link', 'video'].includes(control.value) && ((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
draft.presentationSourcePosition === null (control.value === 'image' && !draft.presentationMediaId))
) { ) {
return; return;
} }
@@ -673,6 +673,7 @@
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} /> <input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} /> <input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
<input <input
type="hidden" type="hidden"
name="presentationMetadataFetchedAtUtc" name="presentationMetadataFetchedAtUtc"
@@ -693,12 +694,10 @@
</div> </div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small> <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div> </div>
{#if data.piece.media.length}<label class="media-none" {#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value=""
checked={!draft.presentationMediaId} checked={!draft.presentationMediaId}
onchange={() => { onchange={() => {
draft.presentationMediaId = null; draft.presentationMediaId = null;
@@ -707,13 +706,11 @@
/> Kein hochgeladenes Bild</label /> Kein hochgeladenes Bild</label
> >
<div class="media-grid"> <div class="media-grid">
{#each data.piece.media as media (media.id)}<figure> {#each draft.media as media (media.id)}<figure>
<label class="media-presentation-choice" <label class="media-presentation-choice"
><input ><input
form="piece-editor-form"
type="radio" type="radio"
name="presentationMediaId" name="presentationMediaChoice"
value={media.id}
checked={draft.presentationMediaId === media.id} checked={draft.presentationMediaId === media.id}
onchange={() => { onchange={() => {
draft.presentationMediaId = media.id; draft.presentationMediaId = media.id;
@@ -14,10 +14,12 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div> <div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay <PresentationDisplay
content={data.content} content={data.content}
imageSrc={data.content.images.length imageSrc={data.content.mode === 'text' && data.content.images.length
? data.content.mode === 'text'
? '/media' ? '/media'
: `/media/${data.content.images[0].id}` : data.content.mode === 'image' && data.content.images.length
: `/fundus/${data.pieceId}/praesentation/preview-image`} ? `/media/${data.content.images[0].id}`
: data.content.mode === 'link' && data.content.imageUrl
? `/fundus/${data.pieceId}/praesentation/preview-image`
: ''}
/> />
</main> </main>
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
export const GET: RequestHandler = async ({ locals, params }) => { export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user); requireUser(locals.user);
const piece = getPiece(params.id); const piece = getPiece(params.id);
if (!piece?.presentationImageUrl) error(404, 'Vorschaubild nicht verfügbar'); if (piece?.presentationMode !== 'link' || !piece.presentationImageUrl) {
error(404, 'Vorschaubild nicht verfügbar');
}
try { try {
const image = await fetchPresentationImage(piece.presentationImageUrl); const image = await fetchPresentationImage(piece.presentationImageUrl);
return new Response(new Uint8Array(image.data), { return new Response(new Uint8Array(image.data), {
+4 -1
View File
@@ -129,7 +129,7 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
}; };
} }
const pieces = listPieces({ const pieces = listPieces({
statuses: ['ready'], statuses: ['preparing', 'ready'],
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
categoryId: url.searchParams.get('category') ?? '' categoryId: url.searchParams.get('category') ?? ''
}); });
@@ -338,6 +338,9 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'), presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'), presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'), presentationSiteName: text(data, 'presentationSiteName'),
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
preparationEffort: (text(data, 'preparationEffort') ||
null) as PieceUsageUpdate['preparationEffort'],
presentationMediaId: text(data, 'presentationMediaId') || null, presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({ tools: toolIds.map((id, index) => ({
id, id,
+1 -1
View File
@@ -431,7 +431,7 @@
<header class="pane-heading"> <header class="pane-heading">
<div> <div>
<p class="eyebrow">Quelle</p> <p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2> <h2 id="source-title">Planbarer Fundus</h2>
</div> </div>
<span>{visiblePieces.length}</span> <span>{visiblePieces.length}</span>
</header> </header>
+2 -2
View File
@@ -32,7 +32,6 @@
> >
{form.success} {form.success}
</div>{/if} </div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<h2 class="section-heading">Schnittstellen</h2> <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">
@@ -312,7 +311,8 @@
</section> </section>
</div> </div>
<h2 class="section-heading">Werkzeuge</h2> <h2 class="section-heading">Werkzeuge & Freigaben</h2>
<ShareManagement shows={data.shows} shares={data.shares} {form} catalogPanel />
<section class="quiet-panel tool-management"> <section class="quiet-panel tool-management">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Sendewerk",
"short_name": "Sendewerk",
"description": "Redaktion, Sendungsplanung und Live-Regie für heinzlive",
"start_url": "/sendungen",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#0b1220",
"lang": "de",
"orientation": "any"
}
+91 -2
View File
@@ -364,6 +364,23 @@ function setSectionDuration(sectionId: string, seconds: number | null) {
database.close(); database.close();
} }
function setUsagePreparation(
usageId: string,
status: 'preparing' | 'ready',
effort: 'low' | 'medium' | 'high' | null
) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database
.prepare(
`UPDATE piece_usages SET preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(status, effort, usageId);
database.close();
}
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('/'); await page.goto('/');
@@ -665,7 +682,6 @@ test('show overview switches persistently between list, calendar and mobile agen
const octoberDay = page const octoberDay = page
.locator('.show-calendar-day') .locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' }); .filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin'); await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2); await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00'); await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
@@ -703,6 +719,43 @@ test('show overview switches persistently between list, calendar and mobile agen
} }
}); });
test('preparation opens and updates the concrete scheduled usage', async ({ page }) => {
setUsagePreparation('usage-console', 'preparing', 'high');
try {
await page.goto('/sendungen');
const preparation = page.locator('.preparation-overview');
await expect(preparation).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(preparation).toContainText('Hoch');
await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click();
await expect(page).toHaveURL(/usage=usage-console/);
const editor = page.getByRole('dialog', {
name: 'Die kleine Konsole mit dem großen Dreh'
});
await expect(editor).toBeVisible();
await editor.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(editor.getByRole('radio', { name: 'Text', exact: true })).toBeChecked();
await editor.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toHaveCount(0);
await editor.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Quelle/URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Kartentitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Video', exact: true }).check();
await expect(editor.getByRole('combobox', { name: 'Video-URL', exact: true })).toBeVisible();
await expect(editor.getByLabel('Videotitel')).toBeVisible();
await editor.getByRole('radio', { name: 'Text', exact: true }).check();
await editor.getByRole('button', { name: 'Sendungstext', exact: true }).click();
await editor.getByText('Dauer und Einplanung', { exact: true }).click();
await editor.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Verwendung speichern' }).click();
await page.goto('/sendungen');
await expect(page.locator('.preparation-overview')).toHaveCount(0);
} finally {
setUsagePreparation('usage-console', 'ready', null);
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); await page.goto('/fundus');
@@ -1277,6 +1330,14 @@ test('live control persists state and exposes only explicit presentation data',
}); });
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Nur Bild', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Nur Bild', exact: true })).toBeChecked();
await page.getByRole('radio', { name: 'Text', exact: true }).check();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.goto('/sendungen/show-october'); await page.goto('/sendungen/show-october');
const plannedConsole = page const plannedConsole = page
.locator('.editable-usage') .locator('.editable-usage')
@@ -1741,10 +1802,31 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
expect(
await page
.locator('.historical-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await page.goto('/verwaltung'); await page.goto('/verwaltung');
const shareCatalog = page.locator('.share-catalog-panel');
await expect(shareCatalog).toBeVisible();
const catalogOrder = await page.evaluate(() => {
const share = document.querySelector('.share-catalog-panel')?.getBoundingClientRect();
const tools = document.querySelector('.tool-management')?.getBoundingClientRect();
return share && tools ? share.bottom <= tools.top : false;
});
expect(catalogOrder).toBe(true);
await page.getByRole('button', { name: 'Lesefreigaben' }).click(); await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management'); const shareManagement = page.locator('.share-management');
await expect(page.locator('.share-management-dialog')).toBeVisible();
const dialogStyle = await page.locator('.share-management-dialog').evaluate((element) => ({
borderRadius: Number.parseFloat(getComputedStyle(element).borderRadius),
background: getComputedStyle(element).backgroundColor
}));
expect(dialogStyle.borderRadius).toBeGreaterThan(0);
expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion'); await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation'); await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort'); await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
@@ -1772,6 +1854,12 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0); await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0); await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible(); await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
expect(
await guestPage
.locator('.shared-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await guestPage.setViewportSize({ width: 390, height: 844 }); await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage); await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true }); await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
@@ -2036,6 +2124,7 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.getByRole('combobox', { name: 'Quelle/URL', 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 page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
@@ -2051,7 +2140,7 @@ test('link preview uses one existing source, persists manual fields and isolates
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' }) preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible(); ).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible(); await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(1); await expect(preview.locator('.output-link-image')).toHaveCount(0);
await preview.screenshot({ await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'), path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true fullPage: true