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