Complete 0.7.1 fixes
This commit is contained in:
1 parent
a08b02c34b
commit
64f55a8b1f
26 files changed
+587
-145
No files matched your search
+8
-8
@@ -4,19 +4,19 @@
|
||||
|
||||
| Bereich | Nachweis | Stand |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Abschnittsdauer und Rückkehr zu früheren Pieces (#1) | Live-Verhaltenstests für geänderte Plandauer, Wiederaufnahme, Abschnittslaufzeit und Gesamtzeit; Chromium-Ablauf | erfüllt am 25.10.2026 |
|
||||
| Mobiler Live-Ablauf und Metadaten (#3, #13) | kleine Live-Regie bei 390 x 700 ohne horizontalen Überlauf; Sheet, Fokus und Viewportgrenzen im Chromium-Ablauf | Chromium erfüllt; Safari und installierte iOS-Webapp offen |
|
||||
| Mobile Sendungskarten (#5) | responsive Karten im vollständigen Chromium-Lauf bei 390 Pixeln geöffnet | erfüllt am 25.10.2026 |
|
||||
| Vorbereitung nach Einplanung (#6, #15) | Servertest schließt ungeplante Pieces aus und liefert konkrete Usage-Snapshots priorisiert mit Themenicons | erfüllt am 25.10.2026 |
|
||||
| Kontextabhängige Präsentation (#8) | Fundus- und Usage-Editor im Chromium-Lauf; Text-, Bild-, Link- und Videozustände durch bestehende Verhaltenstests gedeckt | erfüllt am 25.10.2026 |
|
||||
| Gastansicht und Freigaben (#11, #12) | Gastansicht mobil/desktop, erneut geöffnete Dialogergebnisse und Tokenersatz mit Invalidierungstest | erfüllt am 25.10.2026 |
|
||||
| Iconsuche (#14) | Chromium prüft sichtbares und unmittelbar fokussiertes Suchfeld bei 390, 960 und 1280 Pixeln | erfüllt am 25.10.2026 |
|
||||
| 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`: 18 von 18 Chromium-Abläufen erfolgreich.
|
||||
- `npm run test:e2e`: 19 von 19 Chromium-Abläufen erfolgreich.
|
||||
- Safari auf echtem iOS-Gerät: offen.
|
||||
- Installierte Webapp auf echtem iOS-Gerät: separat offen.
|
||||
|
||||
|
||||
@@ -453,12 +453,13 @@ Präsentationsmedium.
|
||||
|
||||
## Wartungskorrekturen 0.7.1
|
||||
|
||||
Die Vorbereitungsliste projiziert bestehende Daten statt einen zweiten
|
||||
Vorbereitungszustand einzuführen: Sie verbindet aktive Sendungen mit ihren
|
||||
Usage-Snapshots und berücksichtigt nur Originale im Zustand `preparing`.
|
||||
Dadurch erscheinen Mehrfachplanungen als getrennte Sendungsfassungen, während
|
||||
ungeplante Fundus-Pieces ausgeschlossen bleiben. Der Link adressiert die Usage
|
||||
direkt; Titel und Themenicons stammen aus dem stabilen Snapshot.
|
||||
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
|
||||
|
||||
@@ -504,3 +504,17 @@ Weil Freigabetokens nur gehasht gespeichert werden, kann ein bestehender Link
|
||||
nicht erneut angezeigt werden. Ein Ersatzlink rotiert deshalb den Token derselben
|
||||
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`.
|
||||
+15
-9
@@ -2,26 +2,32 @@
|
||||
|
||||
## 0.7.1 - Bedienung und Live-Korrekturen
|
||||
|
||||
Veröffentlicht am 25. Oktober 2026
|
||||
Veröffentlicht am 25. Oktober 2026, Korrekturstand 26. Oktober 2026
|
||||
|
||||
- Live-Abschnittszeiten übernehmen geänderte Plandauern. Frühere erledigte oder
|
||||
übersprungene Pieces können offiziell wiederaufgenommen werden, ohne ihre
|
||||
Abschnittslaufzeit oder die Gesamtzeit zurückzusetzen (#1).
|
||||
- Mobiler Live-Ablauf, lange Metadaten und Sendungskarten halten ihre verfügbare
|
||||
Breite ein. Der Ablauf bleibt als fokussierbares Sheet bedienbar (#3, #5, #13).
|
||||
- „Noch vorzubereiten“ zeigt nur tatsächlich eingeplante unfertige Fassungen
|
||||
aktiver Sendungen, priorisiert sie und verlinkt sie mit ihren Themenicons direkt
|
||||
in den Usage-Editor (#6, #15).
|
||||
- „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).
|
||||
- Gastabschnitte verzichten auf unnötige äußere Karten. Freigabedialoge zeigen
|
||||
den einmaligen Link nach dem Anlegen sofort wieder an, bieten Kopieren und
|
||||
können einen alten Token sicher durch einen neuen ersetzen (#11, #12).
|
||||
- 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.
|
||||
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.1`. Es gibt keine
|
||||
neue Datenbankmigration. 60 Verhaltenstests und 18 Chromium-Abläufe sind
|
||||
- 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
|
||||
|
||||
+16
-11
@@ -13,24 +13,29 @@ dokumentiert.
|
||||
- Explizite Abschnittsdauern werden während der Live-Regie nach Änderungen
|
||||
synchronisiert. Erledigte oder übersprungene Pieces lassen sich bewusst
|
||||
wiederaufnehmen; frühere Abschnitts- und Gesamtzeiten bleiben dabei erhalten.
|
||||
- Der mobile Ablauf liegt innerhalb eines isolierten Live-Containers, und lange
|
||||
Metadaten verbreitern die Regie nicht mehr. Sendungskarten stapeln ihre Inhalte
|
||||
auf kleinen Bildschirmen eindeutig.
|
||||
- Die Vorbereitung zeigt nur konkrete Einplanungen aktiver Sendungen, deren
|
||||
Fundus-Original noch in Vorbereitung ist. Usage-Titel, Sendung, Abschnitt,
|
||||
Priorität und gespeicherte Themenicons führen direkt zur Sendungsfassung.
|
||||
- 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 beim Öffnen sofort das Suchfeld. RSS-Feeds und
|
||||
technische Zugänge folgen in der Verwaltung dem kompakten Katalogmuster.
|
||||
- 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 Gastansicht trennt Abschnitte ohne
|
||||
zusätzliche äußere Karten.
|
||||
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
|
||||
18 von 18 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
|
||||
19 von 19 Chromium-Abläufe. Die Browsermatrix öffnete beide Piece-Editoren,
|
||||
Sendungsübersicht, Verwaltung und Freigabedialoge, Gastansicht sowie Live-Regie
|
||||
bei Desktop- und Mobilbreiten. Safari und die installierte Webapp auf einem
|
||||
echten iOS-Gerät bleiben mangels Gerät getrennt offen.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE piece_usages ADD COLUMN preparation_status_snapshot TEXT NOT NULL DEFAULT 'ready'
|
||||
CHECK (preparation_status_snapshot IN ('preparing', 'ready'));
|
||||
|
||||
ALTER TABLE piece_usages ADD COLUMN preparation_effort_snapshot TEXT
|
||||
CHECK (preparation_effort_snapshot IS NULL OR preparation_effort_snapshot IN ('low', 'medium', 'high'));
|
||||
|
||||
UPDATE piece_usages
|
||||
SET preparation_status_snapshot = CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM pieces p
|
||||
WHERE p.id = piece_usages.original_piece_id AND p.status = 'preparing'
|
||||
) THEN 'preparing'
|
||||
ELSE 'ready'
|
||||
END,
|
||||
preparation_effort_snapshot = (
|
||||
SELECT p.preparation_effort FROM pieces p WHERE p.id = piece_usages.original_piece_id
|
||||
);
|
||||
+73
-5
@@ -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;
|
||||
}
|
||||
@@ -302,8 +365,11 @@
|
||||
}
|
||||
|
||||
.shared-section {
|
||||
padding: 0 0 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: clamp(1rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.historical-section {
|
||||
padding: clamp(1rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.shared-section > header,
|
||||
@@ -1328,6 +1394,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.live-controller-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
margin-left: 0 !important;
|
||||
@@ -1340,6 +1407,7 @@ textarea:focus {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
@@ -2405,7 +2473,7 @@ textarea:focus {
|
||||
.live-page.without-rail .live-rail,
|
||||
.live-rail {
|
||||
display: grid;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
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;
|
||||
@@ -2429,7 +2497,7 @@ textarea:focus {
|
||||
|
||||
.live-rail-backdrop.visible {
|
||||
display: block;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
)
|
||||
);
|
||||
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">
|
||||
@@ -25,7 +32,7 @@
|
||||
if (menu.open) queueMicrotask(() => searchInput.focus());
|
||||
}}
|
||||
>
|
||||
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<summary onclick={openAndFocus}><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<div class="icon-picker-popover">
|
||||
<label
|
||||
><span class="sr-only">Icons durchsuchen</span><input
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
shows,
|
||||
shares,
|
||||
fixedShowId = '',
|
||||
catalogPanel = false,
|
||||
form = null
|
||||
}: {
|
||||
shows: ShareShow[];
|
||||
shares: ShareItem[];
|
||||
fixedShowId?: string;
|
||||
catalogPanel?: boolean;
|
||||
form?: {
|
||||
intent?: string;
|
||||
error?: string;
|
||||
@@ -104,6 +106,27 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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"
|
||||
@@ -111,11 +134,9 @@
|
||||
>
|
||||
<AppIcon name="link" /> Lesefreigaben
|
||||
</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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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 {
|
||||
@@ -32,6 +33,11 @@
|
||||
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();
|
||||
});
|
||||
@@ -43,6 +49,16 @@
|
||||
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;
|
||||
@@ -50,14 +66,46 @@
|
||||
}
|
||||
|
||||
function presentationSourceChanged(event: Event) {
|
||||
const index = Number((event.currentTarget as HTMLSelectElement).value);
|
||||
const source = draft.sources[index];
|
||||
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) => {
|
||||
@@ -139,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"
|
||||
@@ -275,12 +343,12 @@
|
||||
<div class="presentation-settings">
|
||||
<label class="wide-field"
|
||||
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
|
||||
bind:value={selectedSourceId}
|
||||
onchange={presentationSourceChanged}
|
||||
>
|
||||
{#each draft.sources as source, index (source.id)}
|
||||
<option value={index} selected={source.url === draft.presentationSourceUrl}
|
||||
>{source.label || source.url}</option
|
||||
>
|
||||
<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
|
||||
>
|
||||
@@ -332,7 +400,7 @@
|
||||
class="usage-media-picker"
|
||||
>
|
||||
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||
<label
|
||||
{#if draft.presentationMode === 'text'}<label
|
||||
><input
|
||||
type="radio"
|
||||
checked={!draft.presentationMediaId}
|
||||
@@ -341,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
|
||||
@@ -358,31 +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 draft.presentationMode === 'image'}
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{/if}
|
||||
{:else if draft.presentationMode === 'link'}
|
||||
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
|
||||
<p>
|
||||
{draft.presentationDescription ||
|
||||
draft.presentationSourceUrl ||
|
||||
'Noch keine Vorschauangaben.'}
|
||||
</p>
|
||||
{:else}
|
||||
<p>{draft.presentationSourceUrl || 'Noch keine Video-URL ausgewählt.'}</p>
|
||||
{/if}
|
||||
<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,
|
||||
|
||||
@@ -286,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(
|
||||
|
||||
@@ -1224,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')
|
||||
@@ -1244,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,
|
||||
@@ -1254,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
|
||||
@@ -1262,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 {
|
||||
@@ -1292,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;
|
||||
|
||||
@@ -61,31 +61,55 @@ 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('lists only scheduled preparing originals as concrete usage snapshots', () => {
|
||||
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();
|
||||
const usages = listPreparationUsages();
|
||||
database.prepare("UPDATE pieces SET status = 'preparing' WHERE id = 'piece-sounds'").run();
|
||||
const show = getShow('show-october')!;
|
||||
const planned = addPieceUsage(show.id, show.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: show.sections[0].id
|
||||
});
|
||||
|
||||
expect(usages.map((usage) => usage.usageId)).toEqual(['usage-games-console', 'usage-console']);
|
||||
expect(usages[1]).toMatchObject({
|
||||
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: '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', () => {
|
||||
|
||||
+123
-33
@@ -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;
|
||||
@@ -28,7 +30,7 @@ export type PreparationUsage = {
|
||||
showStartsAtUtc: string | null;
|
||||
sectionName: string;
|
||||
title: string;
|
||||
preparationEffort: 'low' | 'medium' | 'high' | null;
|
||||
preparationEffort: PreparationEffort;
|
||||
topics: UsageTopic[];
|
||||
};
|
||||
|
||||
@@ -53,6 +55,8 @@ export type PieceUsage = {
|
||||
originalPieceId: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
preparationStatus: UsagePreparationStatus;
|
||||
preparationEffort: PreparationEffort;
|
||||
durationSeconds: number;
|
||||
title: string;
|
||||
outputTitle: string;
|
||||
@@ -70,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 }[];
|
||||
@@ -186,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;
|
||||
};
|
||||
@@ -231,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;
|
||||
@@ -249,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;
|
||||
};
|
||||
@@ -470,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,
|
||||
@@ -786,16 +796,16 @@ export function listPreparationUsages(): PreparationUsage[] {
|
||||
.prepare(
|
||||
`SELECT u.id AS usage_id, sh.id AS show_id, sh.title AS show_title,
|
||||
sh.starts_at_utc AS show_starts_at_utc, s.name AS section_name,
|
||||
u.title_snapshot, p.preparation_effort
|
||||
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
|
||||
JOIN pieces p ON p.id = u.original_piece_id
|
||||
WHERE sh.status IN ('draft', 'ready', 'live')
|
||||
AND s.is_reserve_bank = 0 AND p.deleted_at_utc IS NULL AND p.status = 'preparing'
|
||||
ORDER BY CASE p.preparation_effort
|
||||
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,
|
||||
sh.starts_at_utc IS NULL, sh.starts_at_utc, s.position, u.position`
|
||||
s.position, u.position`
|
||||
)
|
||||
.all() as {
|
||||
usage_id: string;
|
||||
@@ -804,7 +814,7 @@ export function listPreparationUsages(): PreparationUsage[] {
|
||||
show_starts_at_utc: string | null;
|
||||
section_name: string;
|
||||
title_snapshot: string;
|
||||
preparation_effort: PreparationUsage['preparationEffort'];
|
||||
preparation_effort_snapshot: PreparationUsage['preparationEffort'];
|
||||
}[];
|
||||
const topics = database.prepare(
|
||||
'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
|
||||
@@ -816,7 +826,7 @@ export function listPreparationUsages(): PreparationUsage[] {
|
||||
showStartsAtUtc: row.show_starts_at_utc,
|
||||
sectionName: row.section_name,
|
||||
title: row.title_snapshot,
|
||||
preparationEffort: row.preparation_effort,
|
||||
preparationEffort: row.preparation_effort_snapshot,
|
||||
topics: (topics.all(row.usage_id) as { name: string; icon_name: string }[]).map((topic) => ({
|
||||
name: topic.name,
|
||||
iconName: topic.icon_name
|
||||
@@ -876,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,
|
||||
@@ -1138,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();
|
||||
@@ -1182,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
|
||||
);
|
||||
@@ -1361,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 = ?`
|
||||
)
|
||||
@@ -1387,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');
|
||||
@@ -1397,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')
|
||||
@@ -1414,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,
|
||||
@@ -1442,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);
|
||||
@@ -1523,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'> & {
|
||||
@@ -1545,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,
|
||||
@@ -1573,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
|
||||
);
|
||||
@@ -1601,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
|
||||
@@ -1628,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 = ?,
|
||||
@@ -1640,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,
|
||||
@@ -1663,6 +1691,8 @@ export function refreshPieceUsageFromOriginal(
|
||||
source.presentation_site_name,
|
||||
source.presentation_provider,
|
||||
source.presentation_provider_id,
|
||||
source.status,
|
||||
source.preparation_effort,
|
||||
usageId
|
||||
);
|
||||
snapshotPieceRelations(
|
||||
@@ -1687,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'),
|
||||
@@ -1697,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,
|
||||
@@ -1711,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 = ?,
|
||||
@@ -1720,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,
|
||||
@@ -1744,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) {
|
||||
@@ -1776,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;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</header>
|
||||
<div class="shared-rundown">
|
||||
{#each data.share.sections as section (section)}
|
||||
<section class="shared-section">
|
||||
<section class="quiet-panel shared-section">
|
||||
<header>
|
||||
<div>
|
||||
{#if section.category}<small
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
if (
|
||||
control instanceof HTMLInputElement &&
|
||||
control.name === 'presentationMode' &&
|
||||
['link', 'video'].includes(control.value) &&
|
||||
draft.presentationSourcePosition === null
|
||||
((['link', 'video'].includes(control.value) && draft.presentationSourcePosition === null) ||
|
||||
(control.value === 'image' && !draft.presentationMediaId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -673,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"
|
||||
@@ -693,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;
|
||||
@@ -707,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), {
|
||||
|
||||
@@ -129,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') ?? ''
|
||||
});
|
||||
@@ -338,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>
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
>
|
||||
{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">
|
||||
@@ -312,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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Sendewerk",
|
||||
"short_name": "Sendewerk",
|
||||
"description": "Redaktion, Sendungsplanung und Live-Regie für heinzlive",
|
||||
"start_url": "/sendungen",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b1220",
|
||||
"theme_color": "#0b1220",
|
||||
"lang": "de",
|
||||
"orientation": "any"
|
||||
}
|
||||
+91
-2
@@ -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');
|
||||
@@ -1277,6 +1330,14 @@ test('live control persists state and exposes only explicit presentation data',
|
||||
});
|
||||
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
|
||||
await page.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')
|
||||
@@ -1741,10 +1802,31 @@ test('completion, archive and protected guest sharing work end to end', async ({
|
||||
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
|
||||
await expect(page.getByText('Ü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');
|
||||
@@ -1772,6 +1854,12 @@ test('completion, archive and protected guest sharing work end to end', async ({
|
||||
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
|
||||
await expect(guestPage.getByText('PRIVATE_FREIGABE_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 });
|
||||
@@ -2036,6 +2124,7 @@ test('link preview uses one existing source, persists manual fields and isolates
|
||||
await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
|
||||
await page.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();
|
||||
@@ -2051,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