Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64f55a8b1f | ||
|
|
a08b02c34b |
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"title": "Sendewerk",
|
||||
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
||||
"tagline": "Vom Fundstück bis zur Livesendung",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"healthCheckPath": "/healthz",
|
||||
"httpPort": 3000,
|
||||
"addons": {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 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 26.10.2026 |
|
||||
| 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 26.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) | 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) | gestalteter Einzeldialog, Verwaltungsreihenfolge und Kartenpadding per Chromium; Tokenersatz mit Invalidierungstest | erfüllt am 26.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 run lint`: erfolgreich.
|
||||
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||
- `npm run test:e2e`: 19 von 19 Chromium-Abläufen erfolgreich.
|
||||
- Safari auf echtem iOS-Gerät: offen.
|
||||
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
|
||||
|
||||
## Etappe 7
|
||||
|
||||
| Kriterium | Nachweis | Stand |
|
||||
|
||||
@@ -450,3 +450,18 @@ bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
|
||||
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
|
||||
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
|
||||
Präsentationsmedium.
|
||||
|
||||
## Wartungskorrekturen 0.7.1
|
||||
|
||||
Migration `0019` ergänzt `preparation_status_snapshot` und
|
||||
`preparation_effort_snapshot` an `piece_usages` und übernimmt für vorhandene
|
||||
Einplanungen einmalig die damaligen Originalwerte. Danach ist ausschließlich die
|
||||
konkrete Sendungsfassung maßgeblich. Einplanen, Duplizieren und bewusstes
|
||||
Aktualisieren aus dem Fundus kopieren beide Werte; der Usage-Editor ändert sie
|
||||
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
|
||||
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.
|
||||
@@ -492,3 +492,29 @@ abgewiesen. OpenAPI 3.1 beschreibt diese Grenze maschinenlesbar. Ein MCP-Adapter
|
||||
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
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
@@ -1,5 +1,35 @@
|
||||
# Release Notes
|
||||
|
||||
## 0.7.1 - Bedienung und Live-Korrekturen
|
||||
|
||||
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. 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“ speichert Status und Aufwand an der konkreten Einplanung,
|
||||
priorisiert aktive Sendungen nach Termin und verlinkt mit den Themenicons direkt
|
||||
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
|
||||
durch kontextsensitive Eingaben und passende Vorschauen (#8).
|
||||
- Gast- und historische Abschnitte besitzen klar gepolsterte äußere Glaskarten.
|
||||
Die Freigabeverwaltung liegt direkt vor dem Werkzeugkatalog; der gemeinsame
|
||||
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).
|
||||
- RSS-Feeds und technische Zugänge verwenden in der Verwaltung kompakte
|
||||
Hinzufügen-Einstiege und dieselbe visuelle Listenlogik wie der Werkzeugkatalog.
|
||||
- Mobiles Live-Sheet und Backdrop sind am Viewport verankert. Viewport-Fit,
|
||||
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.
|
||||
|
||||
## 0.7.0 - RSS-Import und externe Assistenz
|
||||
|
||||
Veröffentlicht am 25. Oktober 2026
|
||||
|
||||
@@ -8,6 +8,38 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
|
||||
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
|
||||
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, lange
|
||||
Metadaten verbreitern die Regie nicht mehr und das Sheet ist am mobilen
|
||||
Viewport verankert. `viewport-fit=cover`, Standalone-Metadaten und Manifest
|
||||
aktivieren Safe Areas auch beim Start vom Home-Bildschirm. Sendungskarten
|
||||
stapeln ihre Inhalte auf kleinen Bildschirmen eindeutig.
|
||||
- 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
|
||||
kontextsensitive Felder und Vorschauen. Ausgeblendete Werte bleiben beim
|
||||
Moduswechsel erhalten.
|
||||
- Die Iconsuche fokussiert innerhalb der öffnenden Klick-/Tastaturaktion das
|
||||
Suchfeld. RSS-Feeds und technische Zugänge verwenden gestaltete Formfelder.
|
||||
- Lesefreigaben öffnen nach Serverantworten wieder mit dem einmalig sichtbaren
|
||||
Link, bieten Kopieren und sichere Ersatzlinks. Ersatzlinks invalidieren den
|
||||
alten Token und dessen Gastsitzungen. Die Verwaltung zeigt Freigaben direkt
|
||||
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
|
||||
19 von 19 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
|
||||
|
||||
- Barkeeper verwalten RSS-/Atom-Feeds mit Standardkategorie, Aktivierung und
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sendewerk",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sendewerk",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.17 <25"
|
||||
|
||||
+111
-4
@@ -113,10 +113,51 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
|
||||
@@ -133,6 +174,28 @@
|
||||
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 {
|
||||
align-self: end;
|
||||
}
|
||||
@@ -262,6 +325,13 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.share-result > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.guest-shell {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@@ -294,6 +364,14 @@
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.shared-section {
|
||||
padding: clamp(1rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.historical-section {
|
||||
padding: clamp(1rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.shared-section > header,
|
||||
.historical-section > header,
|
||||
.shared-usage-heading,
|
||||
@@ -1316,6 +1394,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.live-controller-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
margin-left: 0 !important;
|
||||
@@ -1325,7 +1404,10 @@ textarea:focus {
|
||||
|
||||
.live-page {
|
||||
display: grid;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
@@ -1796,7 +1878,7 @@ textarea:focus {
|
||||
|
||||
.live-piece-heading {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.65rem;
|
||||
padding-bottom: 0.5rem;
|
||||
@@ -1819,6 +1901,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.live-current h1 {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
font-size: clamp(1.2rem, 2.2vw, 1.9rem);
|
||||
@@ -2394,7 +2477,7 @@ textarea:focus {
|
||||
z-index: 80;
|
||||
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;
|
||||
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));
|
||||
visibility: hidden;
|
||||
transition:
|
||||
@@ -2431,9 +2514,16 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.live-piece-heading {
|
||||
min-width: 0;
|
||||
gap: 0.3rem 0.45rem;
|
||||
}
|
||||
|
||||
.live-piece-time {
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live-support-actions {
|
||||
display: grid;
|
||||
}
|
||||
@@ -4761,11 +4851,24 @@ textarea:focus {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.preparation-list span {
|
||||
.preparation-copy,
|
||||
.preparation-copy > span:last-child {
|
||||
display: grid;
|
||||
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 {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
@@ -6742,6 +6845,10 @@ textarea:focus {
|
||||
.show-card-facts {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.show-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.show-card-facts {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
+5
-1
@@ -2,8 +2,12 @@
|
||||
<html lang="de" data-theme="light" data-theme-preference="system">
|
||||
<head>
|
||||
<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="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>
|
||||
(function () {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
$props();
|
||||
let query = $state('');
|
||||
let menu: HTMLDetailsElement;
|
||||
let searchInput: HTMLInputElement;
|
||||
let filtered = $derived(
|
||||
classicRegularIcons.filter((icon) =>
|
||||
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
|
||||
@@ -14,18 +15,31 @@
|
||||
)
|
||||
);
|
||||
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>
|
||||
|
||||
<div class="icon-picker">
|
||||
<input type="hidden" name={inputName} value={name} />
|
||||
<details bind:this={menu}>
|
||||
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<details
|
||||
bind:this={menu}
|
||||
ontoggle={() => {
|
||||
if (menu.open) queueMicrotask(() => searchInput.focus());
|
||||
}}
|
||||
>
|
||||
<summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<div class="icon-picker-popover">
|
||||
<label
|
||||
><span class="sr-only">Icons durchsuchen</span><input
|
||||
type="search"
|
||||
placeholder="Icon suchen"
|
||||
bind:value={query}
|
||||
bind:this={searchInput}
|
||||
/></label
|
||||
>
|
||||
<div class="icon-picker-grid">
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
</article>
|
||||
{:else if content.mode === 'image'}
|
||||
<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}
|
||||
</article>
|
||||
{:else if content.mode === 'link'}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type ShareShow = {
|
||||
@@ -26,12 +27,19 @@
|
||||
shows,
|
||||
shares,
|
||||
fixedShowId = '',
|
||||
catalogPanel = false,
|
||||
form = null
|
||||
}: {
|
||||
shows: ShareShow[];
|
||||
shares: ShareItem[];
|
||||
fixedShowId?: string;
|
||||
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
|
||||
catalogPanel?: boolean;
|
||||
form?: {
|
||||
intent?: string;
|
||||
error?: string;
|
||||
success?: string | boolean;
|
||||
shareUrl?: string;
|
||||
} | null;
|
||||
} = $props();
|
||||
|
||||
let visibleShows = $derived(
|
||||
@@ -40,6 +48,17 @@
|
||||
: shows.filter((show) => shares.some((share) => share.showId === show.id))
|
||||
);
|
||||
let dialog: HTMLDialogElement;
|
||||
let copyMessage = $state('');
|
||||
|
||||
onMount(() => {
|
||||
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
|
||||
});
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!form?.shareUrl) return;
|
||||
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
|
||||
copyMessage = 'Link kopiert.';
|
||||
}
|
||||
|
||||
function germanDate(value: string | null, fallback = 'Termin offen') {
|
||||
return value
|
||||
@@ -87,21 +106,49 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
|
||||
{#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"
|
||||
type="button"
|
||||
onclick={() => dialog.showModal()}
|
||||
>
|
||||
<AppIcon name="link" /> Lesefreigaben
|
||||
</button>
|
||||
</button>
|
||||
{/if}
|
||||
<dialog bind:this={dialog} class="share-management-dialog">
|
||||
<section
|
||||
class="quiet-panel share-management"
|
||||
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
|
||||
>
|
||||
<section class="share-management" aria-labelledby={`share-title-${fixedShowId || 'all'}`}>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Gastzugänge</p>
|
||||
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
|
||||
</div>
|
||||
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
|
||||
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
|
||||
<button
|
||||
class="dialog-close"
|
||||
type="button"
|
||||
aria-label="Lesefreigaben schließen"
|
||||
onclick={() => dialog.close()}
|
||||
>
|
||||
<AppIcon name="xmark" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -124,8 +171,15 @@
|
||||
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
|
||||
<strong>Neuer Leselink</strong>
|
||||
<!-- The server returns only a same-origin share path created by this action. -->
|
||||
<div>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={form.shareUrl}>{form.shareUrl}</a>
|
||||
<a href={form.shareUrl}>{form.shareUrl}</a><button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
|
||||
>
|
||||
</div>
|
||||
{#if copyMessage}<small>{copyMessage}</small>{/if}
|
||||
</div>{/if}
|
||||
|
||||
<form class="share-create-form" method="POST" action="?/createShare">
|
||||
@@ -158,7 +212,8 @@
|
||||
/></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>
|
||||
|
||||
{#if visibleShows.length}
|
||||
@@ -229,6 +284,20 @@
|
||||
<button class="secondary-button" type="submit">Freigabe speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if !share.revokedAtUtc}<form
|
||||
method="POST"
|
||||
action="?/replaceShare"
|
||||
onsubmit={(event) => {
|
||||
if (
|
||||
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
|
||||
)
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<button class="text-button" type="submit">Ersatzlink anlegen</button>
|
||||
</form>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteShare"
|
||||
@@ -241,7 +310,8 @@
|
||||
>
|
||||
<input type="hidden" name="shareId" value={share.id} />
|
||||
<input type="hidden" name="showId" value={show.id} />
|
||||
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
|
||||
<button class="text-button danger-text" type="submit">Freigabe löschen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
@@ -256,5 +326,5 @@
|
||||
{:else}
|
||||
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</dialog>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||
import InlineTitleEditor from './InlineTitleEditor.svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
import PieceEditorDialog from './PieceEditorDialog.svelte';
|
||||
import PresentationDisplay from './PresentationDisplay.svelte';
|
||||
import type { PieceUsage } from '#lib/server/shows.js';
|
||||
|
||||
let {
|
||||
@@ -13,7 +14,8 @@
|
||||
sectionId,
|
||||
sections,
|
||||
error,
|
||||
enhanceCommand
|
||||
enhanceCommand,
|
||||
open = false
|
||||
}: {
|
||||
usage: PieceUsage;
|
||||
revision: number;
|
||||
@@ -21,6 +23,7 @@
|
||||
sections: { id: string; name: string }[];
|
||||
error?: string;
|
||||
enhanceCommand: SubmitFunction;
|
||||
open?: boolean;
|
||||
} = $props();
|
||||
|
||||
let formId = $derived(`usage-editor-form-${usage.id}`);
|
||||
@@ -30,6 +33,14 @@
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
let selectedSourceId = $state(
|
||||
untrack(
|
||||
() => usage.sources.find((source) => source.url === usage.presentationSourceUrl)?.id ?? ''
|
||||
)
|
||||
);
|
||||
onMount(() => {
|
||||
if (open) editor.showModal();
|
||||
});
|
||||
let selectedMedia = $derived(
|
||||
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||
);
|
||||
@@ -38,12 +49,63 @@
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
}
|
||||
|
||||
function previewHtml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (dirty && !confirm('Ungespeicherte Änderungen verwerfen und Editor schließen?')) return;
|
||||
dirty = false;
|
||||
editor.close();
|
||||
}
|
||||
|
||||
function presentationSourceChanged(event: Event) {
|
||||
selectedSourceId = (event.currentTarget as HTMLSelectElement).value;
|
||||
const source = draft.sources.find((item) => item.id === selectedSourceId);
|
||||
if (!source) return;
|
||||
draft.presentationSourceLabel = source.label;
|
||||
draft.presentationSourceUrl = source.url;
|
||||
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 finish = await enhanceCommand(input);
|
||||
return async (result) => {
|
||||
@@ -125,6 +187,26 @@
|
||||
<details class="piece-meta-details">
|
||||
<summary>Dauer und Einplanung</summary>
|
||||
<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
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
@@ -212,20 +294,7 @@
|
||||
</div>
|
||||
<div class="piece-presentation-layout">
|
||||
<div class="piece-presentation-controls">
|
||||
<label
|
||||
>Präsentationstitel<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentation"
|
||||
label="Öffentlicher Text"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
bind:value={draft.presentation}
|
||||
/>
|
||||
<p class="field-label">Präsentationsart</p>
|
||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
||||
<label
|
||||
@@ -238,6 +307,25 @@
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if draft.presentationMode === 'text'}
|
||||
<label
|
||||
>Öffentliche Überschrift<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentation"
|
||||
label="Öffentlicher Vorspann"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
bind:value={draft.presentation}
|
||||
/>
|
||||
{:else}
|
||||
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
|
||||
<input type="hidden" name="presentation" value={draft.presentation} />
|
||||
{/if}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationSourceLabel"
|
||||
@@ -253,32 +341,42 @@
|
||||
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
|
||||
<div class="presentation-settings">
|
||||
<div class="presentation-source-summary wide-field">
|
||||
<span>Ausgewählte Quelle</span>
|
||||
<strong
|
||||
>{draft.presentationSourceLabel ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Keine Quelle'}</strong
|
||||
<label class="wide-field"
|
||||
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
|
||||
bind:value={selectedSourceId}
|
||||
onchange={presentationSourceChanged}
|
||||
>
|
||||
<option value="">Quelle auswählen</option>
|
||||
{#each draft.sources as source (source.id)}
|
||||
<option value={source.id}>{source.label || source.url}</option>
|
||||
{/each}
|
||||
</select></label
|
||||
>
|
||||
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
|
||||
</div>
|
||||
<label
|
||||
>Kartentitel<input
|
||||
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
|
||||
name="presentationTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optionaler Anzeigetitel"
|
||||
bind:value={draft.presentationTitle}
|
||||
/></label
|
||||
>
|
||||
{#if draft.presentationMode === 'link'}
|
||||
<label
|
||||
>Bild-URL<input
|
||||
>Vorschaubild-URL<input
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
placeholder="Aus der Linkvorschau"
|
||||
bind:value={draft.presentationImageUrl}
|
||||
/></label
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationImageUrl"
|
||||
value={draft.presentationImageUrl}
|
||||
/>
|
||||
{/if}
|
||||
<label class="wide-field"
|
||||
>Anreißer<textarea
|
||||
name="presentationDescription"
|
||||
@@ -298,9 +396,11 @@
|
||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if draft.media.length}<fieldset class="usage-media-picker">
|
||||
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
|
||||
class="usage-media-picker"
|
||||
>
|
||||
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||
<label
|
||||
{#if draft.presentationMode === 'text'}<label
|
||||
><input
|
||||
type="radio"
|
||||
checked={!draft.presentationMediaId}
|
||||
@@ -309,7 +409,7 @@
|
||||
dirty = true;
|
||||
}}
|
||||
/> Kein Bild</label
|
||||
>
|
||||
>{/if}
|
||||
<div class="media-grid">
|
||||
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
|
||||
><input
|
||||
@@ -326,30 +426,7 @@
|
||||
</div>
|
||||
</fieldset>{/if}
|
||||
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
|
||||
<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 selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{:else if draft.presentationImageUrl}<img
|
||||
src={draft.presentationImageUrl}
|
||||
alt=""
|
||||
/>{/if}
|
||||
<p>
|
||||
{draft.presentationDescription ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Noch keine Vorschauangaben.'}
|
||||
</p>
|
||||
{/if}
|
||||
<PresentationDisplay content={previewContent} imageSrc={previewImageSrc} />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 18,
|
||||
migrations: 19,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
moveOpenUsage,
|
||||
presentationChannelAccess,
|
||||
presentUsage,
|
||||
resumeUsage,
|
||||
rotatePresentationChannelKey,
|
||||
selectUsage,
|
||||
skipCurrent,
|
||||
@@ -59,6 +60,11 @@ describe('live mode', () => {
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 600 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:01:40.000Z'));
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
plannedDurationSeconds: 600,
|
||||
elapsedSeconds: 100
|
||||
});
|
||||
completeCurrent(
|
||||
'show-october',
|
||||
'timer-next-piece',
|
||||
@@ -104,13 +110,54 @@ describe('live mode', () => {
|
||||
.find((usage) => usage.id === 'usage-console')?.status
|
||||
).toBe('done');
|
||||
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision);
|
||||
expect(() =>
|
||||
selectUsage('show-october', 'usage-console', 'return-to-completed', live.liveRevision)
|
||||
).toThrow('INVALID_STATUS');
|
||||
resumeUsage('show-october', 'usage-console', 'resume-completed', live.liveRevision);
|
||||
expect(getLiveView('show-october').current).toMatchObject({
|
||||
id: 'usage-console',
|
||||
status: 'current'
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes a completed piece and its earlier section timer without resetting total time', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
|
||||
)
|
||||
.run();
|
||||
database
|
||||
.prepare("UPDATE show_sections SET explicit_duration_seconds = 200 WHERE id = 'section-game'")
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE piece_usages SET show_section_id = 'section-game', position = 0 WHERE id = 'usage-effects'"
|
||||
)
|
||||
.run();
|
||||
const start = new Date('2026-10-23T18:00:00.000Z');
|
||||
startOrResumeLive('show-october', 'resume-start', 0, start);
|
||||
completeCurrent('show-october', 'resume-next', 1, new Date('2026-10-23T18:01:00.000Z'));
|
||||
let live = getLiveView('show-october', new Date('2026-10-23T18:01:30.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
resumeUsage(
|
||||
'show-october',
|
||||
'usage-console',
|
||||
'resume-earlier',
|
||||
live.liveRevision,
|
||||
new Date('2026-10-23T18:01:30.000Z')
|
||||
);
|
||||
live = getLiveView('show-october', new Date('2026-10-23T18:02:00.000Z'));
|
||||
expect(live.current).toMatchObject({ id: 'usage-console', status: 'current' });
|
||||
expect(live.completedCount).toBe(0);
|
||||
expect(live.sectionTimer).toMatchObject({
|
||||
sectionId: 'section-stories',
|
||||
plannedDurationSeconds: 300,
|
||||
elapsedSeconds: 90
|
||||
});
|
||||
expect(live.totalTimer.elapsedSeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('keeps one stable output channel until its key is explicitly rotated', () => {
|
||||
const key = issuePresentationAccess('show-october');
|
||||
expect(issuePresentationAccess('show-october')).toBe(key);
|
||||
@@ -239,6 +286,40 @@ describe('live mode', () => {
|
||||
expect(media).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');
|
||||
expect(replacementToken).toBe(token);
|
||||
expect(getPresentationView(token)?.content?.title).toBe(
|
||||
|
||||
+73
-10
@@ -149,12 +149,13 @@ function orderedUsages(database: Database.Database, showId: string): OrderedUsag
|
||||
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT s.is_reserve_bank FROM piece_usages u
|
||||
`SELECT s.is_reserve_bank, u.status FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ?`
|
||||
)
|
||||
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
|
||||
.get(usageId, showId) as { is_reserve_bank: number; status: string } | undefined;
|
||||
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
|
||||
return row;
|
||||
}
|
||||
|
||||
function closeActiveRun(database: Database.Database, showId: string, now: string) {
|
||||
@@ -265,8 +266,7 @@ function syncSectionTimer(
|
||||
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.get(showId) as
|
||||
| { id: string; section_id: string; planned_duration_seconds: number }
|
||||
| undefined;
|
||||
{ id: string; section_id: string; planned_duration_seconds: number } | undefined;
|
||||
if (active?.section_id === section.id) {
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
|
||||
closeSectionTimer(database, showId, now);
|
||||
@@ -279,6 +279,29 @@ function syncSectionTimer(
|
||||
}
|
||||
if (active) closeSectionTimer(database, showId, now);
|
||||
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
|
||||
const previous = database
|
||||
.prepare(
|
||||
`SELECT id FROM live_section_timers WHERE show_id = ? AND section_id = ?
|
||||
ORDER BY started_at_utc DESC LIMIT 1`
|
||||
)
|
||||
.get(showId, section.id) as { id: string } | undefined;
|
||||
if (previous) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_section_timers SET planned_duration_seconds = ?, ended_at_utc = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(section.explicit_duration_seconds, previous.id);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), previous.id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const timerId = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
@@ -287,12 +310,14 @@ function syncSectionTimer(
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(timerId, showId, section.id, section.explicit_duration_seconds, now);
|
||||
if (showTimerRunning(database, showId)) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), timerId, now);
|
||||
}
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
@@ -412,6 +437,9 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const state = ensureLiveState(database, showId, now);
|
||||
const show = getShow(showId);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
if (state.started_at_utc && !state.ended_at_utc && state.current_usage_id) {
|
||||
syncSectionTimer(database, showId, state.current_usage_id, now);
|
||||
}
|
||||
const durations = actualDurations(database, showId, now);
|
||||
const sections = show.sections.map((section) => ({
|
||||
...section,
|
||||
@@ -941,7 +969,8 @@ export function selectUsage(
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc) throw new Error('NOT_STARTED');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'open') throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id === usageId) return;
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
@@ -955,6 +984,36 @@ export function selectUsage(
|
||||
);
|
||||
}
|
||||
|
||||
export function resumeUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'resume-usage',
|
||||
(database, state, now) => {
|
||||
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||
const target = usageBelongsToShow(database, showId, usageId);
|
||||
if (target.status !== 'done' && target.status !== 'skipped')
|
||||
throw new Error('INVALID_STATUS');
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare("UPDATE piece_usages SET status = 'open' WHERE id = ?")
|
||||
.run(state.current_usage_id);
|
||||
}
|
||||
activateUsage(database, showId, usageId, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function presentUsage(
|
||||
showId: string,
|
||||
usageId: string | null,
|
||||
@@ -1165,10 +1224,11 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
}
|
||||
| undefined;
|
||||
if (!usage) return { revision: state.presentation_revision, content: null };
|
||||
const mode = usage.presentation_mode_snapshot;
|
||||
return {
|
||||
revision: state.presentation_revision,
|
||||
content: {
|
||||
mode: usage.presentation_mode_snapshot,
|
||||
mode,
|
||||
topics: (
|
||||
database
|
||||
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position')
|
||||
@@ -1185,7 +1245,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
: null,
|
||||
previewTitle: usage.presentation_title_meta_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,
|
||||
provider: usage.presentation_provider_snapshot,
|
||||
providerId: usage.presentation_provider_id_snapshot,
|
||||
@@ -1195,7 +1255,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
usage.presentation_source_timecode_snapshot
|
||||
),
|
||||
sources: [],
|
||||
images: (
|
||||
images: ['text', 'image'].includes(mode)
|
||||
? (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id, alt_text FROM usage_media_snapshots
|
||||
@@ -1203,13 +1264,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
|
||||
)
|
||||
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
|
||||
).map((media) => ({ id: media.id, altText: media.alt_text }))
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPresentationPreviewImage(token: string) {
|
||||
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 {
|
||||
return await fetchPresentationImage(view.content.imageUrl);
|
||||
} catch {
|
||||
@@ -1233,7 +1295,8 @@ export async function getPresentationMedia(
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN piece_media pm ON pm.id = ums.original_media_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
|
||||
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getSharedMedia,
|
||||
getSharedShow,
|
||||
revokeShare,
|
||||
replaceShareToken,
|
||||
updateShare
|
||||
} from './shares.js';
|
||||
import { addPieceUsage, addShowSection, createShow } from './shows.js';
|
||||
@@ -138,8 +139,17 @@ describe('show shares', () => {
|
||||
expect(getSharedShow(shared.token).status).toBe('password');
|
||||
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
|
||||
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
|
||||
deleteShare(actor, shared.id, created.id);
|
||||
const replacementToken = replaceShareToken(actor, shared.id, created.id);
|
||||
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
|
||||
expect(getSharedShow(replacementToken).status).toBe('password');
|
||||
const replacementSession = await authenticateShare(
|
||||
replacementToken,
|
||||
'neues-freigabe-passwort',
|
||||
'192.0.2.20'
|
||||
);
|
||||
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('authorized');
|
||||
deleteShare(actor, shared.id, created.id);
|
||||
expect(getSharedShow(replacementToken, replacementSession.token).status).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('projects exactly the selected information scope', async () => {
|
||||
|
||||
@@ -193,6 +193,27 @@ export async function updateShare(
|
||||
})();
|
||||
}
|
||||
|
||||
export function replaceShareToken(
|
||||
actor: AuthUser,
|
||||
shareId: string,
|
||||
expectedShowId?: string
|
||||
): string {
|
||||
requireBarkeeperActor(actor);
|
||||
const token = createToken();
|
||||
const database = getDatabase();
|
||||
database.transaction(() => {
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE show_shares SET token_hash = ?
|
||||
WHERE id = ? AND revoked_at_utc IS NULL${expectedShowId ? ' AND show_id = ?' : ''}`
|
||||
)
|
||||
.run(hashToken(token), ...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
|
||||
if (result.changes !== 1) throw new Error('NOT_FOUND');
|
||||
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
|
||||
})();
|
||||
return token;
|
||||
}
|
||||
|
||||
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
|
||||
requireBarkeeperActor(actor);
|
||||
const result = getDatabase()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
duplicatePieceUsage,
|
||||
getPieceUsageContexts,
|
||||
getShow,
|
||||
listPreparationUsages,
|
||||
listShowTemplates,
|
||||
movePieceUsage,
|
||||
moveShowSection,
|
||||
@@ -60,12 +61,57 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
||||
presentationImageUrl: edited.presentationImageUrl,
|
||||
presentationSiteName: edited.presentationSiteName,
|
||||
presentationMediaId: edited.presentationMediaId,
|
||||
preparationStatus: edited.preparationStatus,
|
||||
preparationEffort: edited.preparationEffort,
|
||||
ownNotes: edited.ownNotes,
|
||||
durationSeconds: edited.durationSeconds
|
||||
};
|
||||
}
|
||||
|
||||
describe('show builder data layer', () => {
|
||||
it('tracks preparation on the concrete usage independently from its original', () => {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE pieces SET status = 'preparing', preparation_effort = 'high' WHERE id = 'piece-console'"
|
||||
)
|
||||
.run();
|
||||
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
|
||||
});
|
||||
|
||||
database
|
||||
.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',
|
||||
showTitle: 'Herbstsendung: Kleine Welten',
|
||||
sectionName: show.sections[0].name,
|
||||
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);
|
||||
|
||||
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', () => {
|
||||
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(created.id, created.revision, {
|
||||
|
||||
+170
-25
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createPiece } from './content.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 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 EditableShowStatus = Exclude<ShowStatus, 'completed'>;
|
||||
export type UsageStatus = (typeof USAGE_STATUSES)[number];
|
||||
export type UsagePreparationStatus = 'preparing' | 'ready';
|
||||
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
|
||||
|
||||
export type ShowListItem = {
|
||||
id: string;
|
||||
@@ -21,6 +23,17 @@ export type ShowListItem = {
|
||||
sectionCount: number;
|
||||
};
|
||||
|
||||
export type PreparationUsage = {
|
||||
usageId: string;
|
||||
showId: string;
|
||||
showTitle: string;
|
||||
showStartsAtUtc: string | null;
|
||||
sectionName: string;
|
||||
title: string;
|
||||
preparationEffort: PreparationEffort;
|
||||
topics: UsageTopic[];
|
||||
};
|
||||
|
||||
export type ShowTemplateSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +55,8 @@ export type PieceUsage = {
|
||||
originalPieceId: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
preparationStatus: UsagePreparationStatus;
|
||||
preparationEffort: PreparationEffort;
|
||||
durationSeconds: number;
|
||||
title: string;
|
||||
outputTitle: string;
|
||||
@@ -59,7 +74,7 @@ export type PieceUsage = {
|
||||
presentationDescription: string;
|
||||
presentationImageUrl: string;
|
||||
presentationSiteName: string;
|
||||
presentationProvider: string;
|
||||
presentationProvider: VideoProvider | '';
|
||||
presentationProviderId: string;
|
||||
ownNotes: string;
|
||||
sources: { id: string; label: string; url: string; timecodeSeconds: number | null }[];
|
||||
@@ -175,6 +190,8 @@ export type PieceUsageUpdate = {
|
||||
presentationSiteName: string;
|
||||
ownNotes: string;
|
||||
durationSeconds: number;
|
||||
preparationStatus?: UsagePreparationStatus;
|
||||
preparationEffort?: PreparationEffort;
|
||||
tools?: { id: string; workUrl: string; note: string }[];
|
||||
presentationMediaId?: string | null;
|
||||
};
|
||||
@@ -220,6 +237,8 @@ type UsageRow = {
|
||||
original_piece_id: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
preparation_status_snapshot: UsagePreparationStatus;
|
||||
preparation_effort_snapshot: PreparationEffort;
|
||||
duration_seconds: number;
|
||||
title_snapshot: string;
|
||||
output_title_snapshot: string;
|
||||
@@ -238,7 +257,7 @@ type UsageRow = {
|
||||
presentation_description_snapshot: string;
|
||||
presentation_image_url_snapshot: string;
|
||||
presentation_site_name_snapshot: string;
|
||||
presentation_provider_snapshot: string;
|
||||
presentation_provider_snapshot: VideoProvider | '';
|
||||
presentation_provider_id_snapshot: string;
|
||||
own_notes: string;
|
||||
};
|
||||
@@ -459,6 +478,8 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
||||
originalPieceId: row.original_piece_id,
|
||||
position: row.position,
|
||||
status: row.status,
|
||||
preparationStatus: row.preparation_status_snapshot,
|
||||
preparationEffort: row.preparation_effort_snapshot,
|
||||
durationSeconds: row.duration_seconds,
|
||||
title: row.title_snapshot,
|
||||
outputTitle: row.output_title_snapshot,
|
||||
@@ -769,6 +790,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, u.preparation_effort_snapshot
|
||||
FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN shows sh ON sh.id = s.show_id
|
||||
WHERE sh.status IN ('draft', 'ready', 'live')
|
||||
AND s.is_reserve_bank = 0 AND u.preparation_status_snapshot = 'preparing'
|
||||
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,
|
||||
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_snapshot: 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_snapshot,
|
||||
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
|
||||
name: topic.name,
|
||||
iconName: topic.icon_name
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function listShowTemplates(): ShowTemplate[] {
|
||||
const database = getDatabase();
|
||||
const templates = database
|
||||
@@ -821,6 +886,7 @@ export function getShow(id: string): Show | null {
|
||||
const usageRows = database
|
||||
.prepare(
|
||||
`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.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,
|
||||
@@ -1083,8 +1149,9 @@ export function duplicateCompletedShow(
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const section of sections) {
|
||||
const sectionId = randomUUID();
|
||||
@@ -1127,6 +1194,8 @@ export function duplicateCompletedShow(
|
||||
usage.presentation_provider_snapshot,
|
||||
usage.presentation_provider_id_snapshot,
|
||||
usage.presentation_image_only_snapshot,
|
||||
usage.preparation_status_snapshot,
|
||||
usage.preparation_effort_snapshot,
|
||||
usage.own_notes,
|
||||
usage.is_reserve
|
||||
);
|
||||
@@ -1306,7 +1375,8 @@ export function addPieceUsage(
|
||||
COALESCE(ps.url, '') AS presentation_source_url,
|
||||
ps.timecode_seconds AS presentation_source_timecode,
|
||||
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
|
||||
AND ps.position = p.presentation_source_position WHERE p.id = ?`
|
||||
)
|
||||
@@ -1332,6 +1402,7 @@ export function addPieceUsage(
|
||||
presentation_provider: string;
|
||||
presentation_provider_id: string;
|
||||
status: string;
|
||||
preparation_effort: PreparationEffort;
|
||||
}
|
||||
| undefined;
|
||||
if (!piece) throw new Error('NOT_FOUND');
|
||||
@@ -1342,7 +1413,7 @@ export function addPieceUsage(
|
||||
) {
|
||||
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 = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
@@ -1359,8 +1430,9 @@ export function addPieceUsage(
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1387,6 +1459,8 @@ export function addPieceUsage(
|
||||
piece.presentation_site_name,
|
||||
piece.presentation_provider,
|
||||
piece.presentation_provider_id,
|
||||
piece.status,
|
||||
piece.preparation_effort,
|
||||
input.sectionId === null ? 1 : 0
|
||||
);
|
||||
snapshotPieceRelations(database, id, input.pieceId);
|
||||
@@ -1468,6 +1542,7 @@ export function duplicatePieceUsage(
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot,
|
||||
own_notes, is_reserve FROM piece_usages WHERE id = ?`
|
||||
)
|
||||
.get(usageId) as Omit<UsageRow, 'id' | 'show_section_id' | 'position'> & {
|
||||
@@ -1490,8 +1565,9 @@ export function duplicatePieceUsage(
|
||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
presentation_provider_snapshot, presentation_provider_id_snapshot,
|
||||
preparation_status_snapshot, preparation_effort_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1518,6 +1594,8 @@ export function duplicatePieceUsage(
|
||||
source.presentation_site_name_snapshot,
|
||||
source.presentation_provider_snapshot,
|
||||
source.presentation_provider_id_snapshot,
|
||||
source.preparation_status_snapshot,
|
||||
source.preparation_effort_snapshot,
|
||||
source.own_notes,
|
||||
source.is_reserve
|
||||
);
|
||||
@@ -1546,7 +1624,8 @@ export function refreshPieceUsageFromOriginal(
|
||||
COALESCE(ps.url, '') AS presentation_source_url,
|
||||
ps.timecode_seconds AS presentation_source_timecode,
|
||||
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
|
||||
LEFT JOIN piece_sources ps ON ps.piece_id = p.id
|
||||
AND ps.position = p.presentation_source_position
|
||||
@@ -1573,9 +1652,12 @@ export function refreshPieceUsageFromOriginal(
|
||||
presentation_site_name: string;
|
||||
presentation_provider: string;
|
||||
presentation_provider_id: string;
|
||||
status: string;
|
||||
preparation_effort: PreparationEffort;
|
||||
}
|
||||
| undefined;
|
||||
if (!source) throw new Error('ORIGINAL_NOT_FOUND');
|
||||
if (!['preparing', 'ready'].includes(source.status)) throw new Error('PIECE_NOT_READY');
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
|
||||
@@ -1585,7 +1667,8 @@ export function refreshPieceUsageFromOriginal(
|
||||
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
|
||||
presentation_description_snapshot = ?, presentation_image_url_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(
|
||||
source.title,
|
||||
@@ -1608,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.presentation_site_name,
|
||||
source.presentation_provider,
|
||||
source.presentation_provider_id,
|
||||
source.status,
|
||||
source.preparation_effort,
|
||||
usageId
|
||||
);
|
||||
snapshotPieceRelations(
|
||||
@@ -1632,6 +1717,18 @@ export function updatePieceUsage(
|
||||
const title = cleanText(input.title, 200, 'title', true);
|
||||
const outputTitle = cleanText(input.outputTitle, 300, 'output_title');
|
||||
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 = {
|
||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||
research: cleanText(input.research, 50_000, 'research'),
|
||||
@@ -1642,7 +1739,7 @@ export function updatePieceUsage(
|
||||
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
||||
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
||||
};
|
||||
const presentation = validPresentation(
|
||||
const submittedPresentation = validPresentation(
|
||||
input.presentationMode,
|
||||
input.presentationSourceLabel,
|
||||
input.presentationSourceUrl,
|
||||
@@ -1656,6 +1753,57 @@ export function updatePieceUsage(
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
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
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
||||
@@ -1665,7 +1813,8 @@ export function updatePieceUsage(
|
||||
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
|
||||
presentation_image_url_snapshot = ?, presentation_site_name_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(
|
||||
title,
|
||||
@@ -1689,6 +1838,10 @@ export function updatePieceUsage(
|
||||
presentation.providerId,
|
||||
values.ownNotes,
|
||||
duration,
|
||||
input.preparationStatus ?? currentPreparation.preparation_status_snapshot,
|
||||
input.preparationEffort === undefined
|
||||
? currentPreparation.preparation_effort_snapshot
|
||||
: input.preparationEffort,
|
||||
usageId
|
||||
);
|
||||
if (input.tools) {
|
||||
@@ -1721,23 +1874,15 @@ export function updatePieceUsage(
|
||||
}
|
||||
}
|
||||
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
|
||||
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
|
||||
.run(usageId);
|
||||
if (input.presentationMediaId !== null) {
|
||||
if (selectedMediaId !== null) {
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
|
||||
)
|
||||
.run(input.presentationMediaId, usageId);
|
||||
.run(selectedMediaId, usageId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -77,14 +77,19 @@
|
||||
: draft.presentationProvider === 'tiktok' && 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(
|
||||
selectedMedia
|
||||
selectedMedia && ['text', 'image'].includes(draft.presentationMode)
|
||||
? draft.presentationMode === 'text'
|
||||
? '/media'
|
||||
: `/media/${selectedMedia.id}`
|
||||
: sourcePreviewImage
|
||||
: draft.presentationMode === 'link'
|
||||
? sourcePreviewImage
|
||||
: ''
|
||||
);
|
||||
let pendingHref: string | null = null;
|
||||
let allowNavigation = false;
|
||||
@@ -116,8 +121,8 @@
|
||||
if (
|
||||
control instanceof HTMLInputElement &&
|
||||
control.name === 'presentationMode' &&
|
||||
control.value !== 'text' &&
|
||||
draft.presentationSourcePosition === null
|
||||
((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
|
||||
(control.value === 'image' && !draft.presentationMediaId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -348,10 +353,11 @@
|
||||
/></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
|
||||
>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
|
||||
@@ -533,21 +539,7 @@
|
||||
</div>
|
||||
<div class="piece-presentation-layout">
|
||||
<div class="piece-presentation-controls">
|
||||
<label
|
||||
>Präsentationstitel<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="presentationText"
|
||||
label="Öffentlicher Text"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
rows={8}
|
||||
bind:value={draft.presentationText}
|
||||
/>
|
||||
<p class="field-label">Präsentationsart</p>
|
||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
||||
><input
|
||||
@@ -558,9 +550,30 @@
|
||||
/>{mode[1]}</label
|
||||
>{/each}
|
||||
</div>
|
||||
{#if draft.presentationMode === 'text'}
|
||||
<label
|
||||
>Öffentliche Überschrift<input
|
||||
name="outputTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optional, sonst interner Titel"
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
name="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">
|
||||
<label
|
||||
>Quelle<select
|
||||
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
|
||||
name="presentationSourcePosition"
|
||||
bind:value={draft.presentationSourcePosition}
|
||||
onchange={presentationSourceChanged}
|
||||
@@ -571,7 +584,7 @@
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Kartentitel<input
|
||||
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
|
||||
name="presentationTitle"
|
||||
maxlength="300"
|
||||
placeholder="Optionaler Anzeigetitel"
|
||||
@@ -579,16 +592,24 @@
|
||||
oninput={() => (draft.presentationTitleManual = true)}
|
||||
/></label
|
||||
>
|
||||
{#if draft.presentationMode === 'link'}
|
||||
<label
|
||||
>Bild-URL<input
|
||||
>Vorschaubild-URL<input
|
||||
name="presentationImageUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
placeholder="Wird aus der Linkvorschau geladen"
|
||||
bind:value={draft.presentationImageUrl}
|
||||
oninput={() => (draft.presentationImageManual = true)}
|
||||
/></label
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationImageUrl"
|
||||
value={draft.presentationImageUrl}
|
||||
/>
|
||||
{/if}
|
||||
<label class="wide-field"
|
||||
>Anreißer<textarea
|
||||
name="presentationDescription"
|
||||
@@ -612,6 +633,20 @@
|
||||
>
|
||||
</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>
|
||||
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||
<PresentationDisplay
|
||||
@@ -638,6 +673,7 @@
|
||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
|
||||
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
|
||||
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="presentationMetadataFetchedAtUtc"
|
||||
@@ -646,7 +682,11 @@
|
||||
</section>
|
||||
</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>
|
||||
<p class="eyebrow">Hochgeladene Bilder</p>
|
||||
@@ -654,12 +694,10 @@
|
||||
</div>
|
||||
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
|
||||
</div>
|
||||
{#if data.piece.media.length}<label class="media-none"
|
||||
{#if draft.media.length && draft.presentationMode === 'text'}<label class="media-none"
|
||||
><input
|
||||
form="piece-editor-form"
|
||||
type="radio"
|
||||
name="presentationMediaId"
|
||||
value=""
|
||||
name="presentationMediaChoice"
|
||||
checked={!draft.presentationMediaId}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = null;
|
||||
@@ -668,13 +706,11 @@
|
||||
/> Kein hochgeladenes Bild</label
|
||||
>
|
||||
<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"
|
||||
><input
|
||||
form="piece-editor-form"
|
||||
type="radio"
|
||||
name="presentationMediaId"
|
||||
value={media.id}
|
||||
name="presentationMediaChoice"
|
||||
checked={draft.presentationMediaId === media.id}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = media.id;
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
|
||||
<PresentationDisplay
|
||||
content={data.content}
|
||||
imageSrc={data.content.images.length
|
||||
? data.content.mode === 'text'
|
||||
imageSrc={data.content.mode === 'text' && data.content.images.length
|
||||
? '/media'
|
||||
: `/media/${data.content.images[0].id}`
|
||||
: `/fundus/${data.pieceId}/praesentation/preview-image`}
|
||||
: data.content.mode === 'image' && data.content.images.length
|
||||
? `/media/${data.content.images[0].id}`
|
||||
: data.content.mode === 'link' && data.content.imageUrl
|
||||
? `/fundus/${data.pieceId}/praesentation/preview-image`
|
||||
: ''}
|
||||
/>
|
||||
</main>
|
||||
@@ -7,7 +7,9 @@ import { fetchPresentationImage } from '#lib/server/presentation.js';
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
requireUser(locals.user);
|
||||
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 {
|
||||
const image = await fetchPresentationImage(piece.presentationImageUrl);
|
||||
return new Response(new Uint8Array(image.data), {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
rotatePresentationChannelKey,
|
||||
resetSectionTimer,
|
||||
resetTotalTimer,
|
||||
resumeUsage,
|
||||
resumeEndedLive,
|
||||
resumeLive,
|
||||
selectUsage,
|
||||
@@ -25,7 +26,13 @@ import {
|
||||
startOrResumeLive
|
||||
} from '#lib/server/live.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';
|
||||
|
||||
function text(data: FormData, name: string) {
|
||||
@@ -99,10 +106,16 @@ export const actions = {
|
||||
const data = await request.formData();
|
||||
try {
|
||||
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')
|
||||
});
|
||||
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
|
||||
return {
|
||||
intent: 'createShare',
|
||||
success: 'Leselink angelegt.',
|
||||
shareUrl: `/freigabe/${share.token}`
|
||||
};
|
||||
} catch (cause) {
|
||||
const problem = actionError(cause);
|
||||
return fail(problem.status, { intent: 'createShare', ...problem });
|
||||
@@ -112,16 +125,38 @@ export const actions = {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
await updateShare(actor, text(data, 'shareId'), {
|
||||
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
|
||||
await updateShare(
|
||||
actor,
|
||||
text(data, 'shareId'),
|
||||
{
|
||||
scope: text(data, 'scope'),
|
||||
passwordMode: text(data, 'passwordMode'),
|
||||
password: text(data, 'password'),
|
||||
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
|
||||
}, text(data, 'showId'));
|
||||
},
|
||||
text(data, 'showId')
|
||||
);
|
||||
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
|
||||
} catch (cause) {
|
||||
const problem = actionError(cause);
|
||||
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 }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
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 }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -591,7 +591,12 @@
|
||||
</section>
|
||||
<section>
|
||||
<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}>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
|
||||
@@ -814,8 +819,13 @@
|
||||
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
|
||||
</strong>{/if}
|
||||
</div>
|
||||
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
|
||||
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
|
||||
{#if viewedSection?.twitchTags.length}<button
|
||||
class="text-button live-twitch-tags"
|
||||
type="button"
|
||||
onclick={copyTwitchTags}
|
||||
>
|
||||
<AppIcon name="copy" />
|
||||
{viewedSection.twitchTags.join(' · ')}
|
||||
</button>{/if}
|
||||
|
||||
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
|
||||
@@ -1000,6 +1010,22 @@
|
||||
>
|
||||
</button>
|
||||
<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">
|
||||
<summary aria-label={`${usage.title} sortieren`}
|
||||
><AppIcon name="ellipsis" /></summary
|
||||
@@ -1176,6 +1202,19 @@
|
||||
: 'Präsentieren'}
|
||||
</button>
|
||||
</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}>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||
@@ -1208,6 +1247,7 @@
|
||||
: 'Erledigen & Sendungsende'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
|
||||
>{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,12 @@ import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
|
||||
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
|
||||
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
|
||||
import { listPieces } from '#lib/server/content.js';
|
||||
import {
|
||||
createShow,
|
||||
listPreparationUsages,
|
||||
listShows,
|
||||
listShowTemplates
|
||||
} from '#lib/server/shows.js';
|
||||
import { berlinLocalToUtc } from '#lib/server/time.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
@@ -13,7 +17,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
|
||||
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
|
||||
return {
|
||||
shows: listShows(['draft', 'ready', 'live']),
|
||||
preparationPieces: listPieces({ statuses: ['preparing'] }),
|
||||
preparationUsages: listPreparationUsages(),
|
||||
templates: listShowTemplates(),
|
||||
view: getShowOverviewView(user.id),
|
||||
selectedMonth,
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
}).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) {
|
||||
if (status === 'ready') return 'Bereit';
|
||||
if (status === 'draft') return 'Entwurf';
|
||||
@@ -125,13 +129,41 @@
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
|
||||
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
|
||||
{#if data.preparationUsages.length}<section
|
||||
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">
|
||||
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
|
||||
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
|
||||
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
|
||||
</a>{/each}
|
||||
{#each data.preparationUsages as usage (usage.usageId)}
|
||||
<!-- The helper starts from a route resolved against the configured base path. -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<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>
|
||||
</section>{/if}
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
|
||||
import { getHistoricalActualDurations } from '#lib/server/completion.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 {
|
||||
addPieceUsage,
|
||||
@@ -123,7 +129,7 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
};
|
||||
}
|
||||
const pieces = listPieces({
|
||||
statuses: ['ready'],
|
||||
statuses: ['preparing', 'ready'],
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? ''
|
||||
});
|
||||
@@ -192,6 +198,20 @@ export const actions = {
|
||||
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 }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
@@ -318,6 +338,9 @@ export const actions = {
|
||||
presentationDescription: text(data, 'presentationDescription'),
|
||||
presentationImageUrl: text(data, 'presentationImageUrl'),
|
||||
presentationSiteName: text(data, 'presentationSiteName'),
|
||||
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
|
||||
preparationEffort: (text(data, 'preparationEffort') ||
|
||||
null) as PieceUsageUpdate['preparationEffort'],
|
||||
presentationMediaId: text(data, 'presentationMediaId') || null,
|
||||
tools: toolIds.map((id, index) => ({
|
||||
id,
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
<header class="pane-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Quelle</p>
|
||||
<h2 id="source-title">Sendefertiger Fundus</h2>
|
||||
<h2 id="source-title">Planbarer Fundus</h2>
|
||||
</div>
|
||||
<span>{visiblePieces.length}</span>
|
||||
</header>
|
||||
@@ -661,6 +661,7 @@
|
||||
{#if section.usages.length}<div class="usage-list">
|
||||
{#each section.usages as usage, usageIndex (usage.id)}
|
||||
<div
|
||||
id={`usage-${usage.id}`}
|
||||
class="usage-drag-slot"
|
||||
role="presentation"
|
||||
ondragover={(event) => {
|
||||
@@ -750,6 +751,7 @@
|
||||
</form>
|
||||
<UsageEditorDialog
|
||||
{usage}
|
||||
open={page.url.searchParams.get('usage') === usage.id}
|
||||
revision={data.show.revision}
|
||||
sectionId={section.id}
|
||||
sections={data.show.sections}
|
||||
@@ -893,7 +895,11 @@
|
||||
placeholder="optional, Sekunden"
|
||||
/></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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
type TaxonomyKind
|
||||
} from '#lib/server/content.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 { berlinLocalToUtc } from '#lib/server/time.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) });
|
||||
}
|
||||
},
|
||||
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 }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
>
|
||||
{form.success}
|
||||
</div>{/if}
|
||||
<ShareManagement shows={data.shows} shares={data.shares} {form} />
|
||||
<h2 class="section-heading">Schnittstellen</h2>
|
||||
<section class="quiet-panel integration-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
@@ -45,6 +45,8 @@
|
||||
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
|
||||
Eingang.
|
||||
</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">
|
||||
<label>Name<input name="name" required maxlength="120" /></label>
|
||||
<label
|
||||
@@ -79,6 +81,7 @@
|
||||
>
|
||||
<button class="primary-button" type="submit">Feed anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="integration-list">
|
||||
{#each data.feeds as feed (feed.id)}
|
||||
<details>
|
||||
@@ -169,9 +172,16 @@
|
||||
{#if form?.technicalToken}<div class="token-reveal" role="status">
|
||||
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
|
||||
</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">
|
||||
<label
|
||||
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label
|
||||
>Name<input
|
||||
name="name"
|
||||
required
|
||||
maxlength="120"
|
||||
placeholder="Redaktionsassistenz"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Berechtigung<select name="permissions"
|
||||
@@ -182,6 +192,7 @@
|
||||
>
|
||||
<button class="primary-button" type="submit">Zugang anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="integration-list">
|
||||
{#each data.technicalAccesses as access (access.id)}
|
||||
<div class="access-row">
|
||||
@@ -300,7 +311,8 @@
|
||||
</section>
|
||||
</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">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
@@ -456,6 +468,17 @@
|
||||
gap: 0.8rem;
|
||||
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) {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
@@ -472,8 +495,10 @@
|
||||
}
|
||||
.integration-list details,
|
||||
.access-row {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.8rem;
|
||||
padding: 0.75rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.integration-list summary,
|
||||
.access-row {
|
||||
@@ -488,7 +513,7 @@
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.integration-list small {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+101
-7
@@ -364,6 +364,23 @@ function setSectionDuration(sectionId: string, seconds: number | null) {
|
||||
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 }) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto('/');
|
||||
@@ -665,7 +682,6 @@ test('show overview switches persistently between list, calendar and mobile agen
|
||||
const octoberDay = page
|
||||
.locator('.show-calendar-day')
|
||||
.filter({ hasText: 'Kalender Frühtermin' });
|
||||
await expect(octoberDay).toHaveClass(/today/);
|
||||
await expect(octoberDay).toContainText('Kalender Spättermin');
|
||||
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
|
||||
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) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto('/fundus');
|
||||
@@ -1106,6 +1159,7 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
|
||||
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
|
||||
const popover = topicIconPicker.locator('.icon-picker-popover');
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
@@ -1276,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.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');
|
||||
const plannedConsole = page
|
||||
.locator('.editable-usage')
|
||||
@@ -1414,8 +1476,11 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
.locator('.live-run-item.current')
|
||||
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled();
|
||||
await expect(
|
||||
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(
|
||||
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
|
||||
).toBeVisible();
|
||||
@@ -1737,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('Übersprungener Abschlussbeitrag')).toBeVisible();
|
||||
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');
|
||||
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();
|
||||
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('Informationsumfang').selectOption('moderation');
|
||||
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
|
||||
@@ -1768,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_NOTIZ')).toHaveCount(0);
|
||||
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 expectNoHorizontalOverflow(guestPage);
|
||||
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
|
||||
@@ -1786,6 +1878,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
|
||||
expect(guestWrite.status()).toBe(403);
|
||||
|
||||
await page.goto('/verwaltung');
|
||||
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
|
||||
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
|
||||
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
|
||||
const shareRow = showCard.locator('.share-row').first();
|
||||
@@ -1854,7 +1947,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
||||
);
|
||||
await openEditorialField(page, 'Präsentationstext');
|
||||
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**');
|
||||
await openEditorialField(page, 'Private Recherche');
|
||||
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
|
||||
@@ -2002,7 +2095,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('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 });
|
||||
|
||||
const previewPromise = page.waitForEvent('popup');
|
||||
@@ -2028,9 +2121,10 @@ test('link preview uses one existing source, persists manual fields and isolates
|
||||
await page.goto('/fundus/piece-console');
|
||||
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||
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('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 page.reload();
|
||||
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||
@@ -2046,7 +2140,7 @@ test('link preview uses one existing source, persists manual fields and isolates
|
||||
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
|
||||
).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({
|
||||
path: testInfo.outputPath('linkvorschau-desktop.png'),
|
||||
fullPage: true
|
||||
|
||||
Reference in new issue
Block a user