Release 0.6.1

This commit is contained in:
hnzio committed 2026-10-25 18:18:23 +01:00
1 parent b80dded55d
commit d586fbd8ce
29 files changed
+397 -65

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.6.0", "version": "0.6.1",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+21
View File
@@ -1,5 +1,26 @@
# Abnahme # Abnahme
## Wartungsrelease 0.6.1
| Bereich | Stand |
| --- | --- |
| Live-Timer, Kopfzeile, Drawer und Positionsanzeige | behoben |
| Mobile Formulare und Iconsuche | behoben |
| Vorbereitungsaufwand und Übersicht | behoben |
| Bild-Präsentationsmodus | behoben |
| Twitch-Tags je Abschnitt | behoben |
| Sendungskarten und Gastabstände | behoben |
| Gemeinsamer Freigabedialog einschließlich Live-Regie | behoben |
| Editorzustand und Drag-and-drop | bestehende zustandserhaltende bzw. alternative Bedienwege unverändert; Browserprüfung offen |
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm test`: 52 von 52 Verhaltenstests erfolgreich.
- `npm run build`: erfolgreich.
- `npm run test:e2e`: zweiter Lauf mit 14 erfolgreichen Abläufen; Live-Regie und
Abschluss/Freigaben schlugen fehl, zwei nachfolgende Abläufe wurden wegen des
120-Sekunden-Zeitlimits nicht mehr ausgeführt. Die Browsermatrix ist offen.
- Safari auf echtem iOS-Gerät: nicht verfügbar.
## Etappe 1 ## Etappe 1
| Kriterium | Nachweis | Stand | | Kriterium | Nachweis | Stand |
+9
View File
@@ -404,3 +404,12 @@ betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe für
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits. DNS-Auflösung und Redirects und besitzen Größen-, Zeit- und Redirectlimits.
## Wartungsfelder 0.6.1
Migration `0017` ergänzt den optionalen Vorbereitungsaufwand am Original und
kommagetrennt validierte Twitch-Tags am Abschnitt. Der Bildmodus wird wegen der
bestehenden SQLite-Check-Constraints als zusätzliches Kennzeichen an Original
und Usage-Snapshot gespeichert; nach außen bleibt er der eindeutige Modus
`image`. Öffentlich passiert weiterhin nur das ausdrücklich gewählte
Präsentationsmedium.
+8
View File
@@ -451,3 +451,11 @@ oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
Geschäftslogik und bleiben Barkeepern vorbehalten. Geschäftslogik und bleiben Barkeepern vorbehalten.
## D-028: Additive Wartungsfelder und gemeinsamer Freigabedialog
Vorbereitungsaufwand und Twitch-Tags sind optionale additive Felder. Der neue
Bildmodus verwendet ein separates boolesches Kennzeichen, damit bestehende
Tabellen und Daten trotz älterer Modus-Check-Constraints ohne riskanten
Tabellenumbau erhalten bleiben. Freigabeverwaltung bleibt eine gemeinsame
Komponente und öffnet in allen drei internen Einstiegen als nativer Dialog.
+11
View File
@@ -1,5 +1,16 @@
# Release Notes # Release Notes
## 0.6.1 - Redaktionelle Wartung
Veröffentlicht am 25. Oktober 2026
- Live-Countdowns übernehmen geänderte Abschnittsplanzeiten, ohne Laufzeit zu verlieren; die mobile Regie gruppiert ihre Kopfaktionen robuster und zeigt den Ablauf als oberstes Sheet.
- Mobile Formularfelder und die Iconsuche vermeiden iOS-Fokuszoom; Sendungskarten und Gastansicht erhalten klarere Abstände.
- Pieces führen optionalen Vorbereitungsaufwand, die Sendungsübersicht bündelt offene Vorbereitung und Abschnitte speichern kopierbare Twitch-Tags.
- Präsentationen können ein ausdrücklich gewähltes Bild ohne Begleittext ausgeben; Originale und Sendungsfassungen bleiben getrennte Snapshots.
- Lesefreigaben öffnen in Sendung, Verwaltung und Live-Regie über denselben Dialog.
- Migration `0017_maintenance_fields.sql` ergänzt ausschließlich optionale, rückwärtskompatible Felder.
## 0.6.0 - Abschluss, Archiv und Freigaben ## 0.6.0 - Abschluss, Archiv und Freigaben
Veröffentlicht am 25. Oktober 2026 Veröffentlicht am 25. Oktober 2026
+13
View File
@@ -7,6 +7,19 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie, Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv und sichere Lesefreigaben sind dokumentiert. Archiv und sichere Lesefreigaben sind dokumentiert.
## Wartungsrelease 0.6.1
- Abschnitts-Countdown, mobile Live-Kopfzeile und Ablauf-Sheet wurden korrigiert; die doppelte Piece-Positionsanzeige entfällt.
- Optionaler Vorbereitungsaufwand, kompakte Vorbereitungsliste und Abschnitts-Twitch-Tags sind additiv persistiert.
- Der Präsentationsmodus `image` gibt ausschließlich das bewusst gewählte Snapshot-Medium aus.
- Mobile Eingaben, Iconsuche, Sendungskarten und Gastabstände wurden angepasst.
- Lesefreigaben verwenden einen gemeinsamen Dialog und sind auch aus der Live-Regie erreichbar.
Geprüft wurden 52 Verhaltenstests, Typprüfung und Produktionsbuild. Im zweiten
Chromium-Lauf waren 14 Abläufe erfolgreich; zwei Abläufe schlugen vor dem
Zeitlimit fehl, zwei weitere wurden nicht mehr ausgeführt. Die vollständige
Browsermatrix bleibt damit offen.
## Abschluss, Archiv und Freigaben 0.6.0 ## Abschluss, Archiv und Freigaben 0.6.0
- das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte, - das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte,
+10
View File
@@ -0,0 +1,10 @@
ALTER TABLE pieces ADD COLUMN preparation_effort TEXT CHECK (
preparation_effort IS NULL OR preparation_effort IN ('low', 'medium', 'high')
);
ALTER TABLE pieces ADD COLUMN presentation_image_only INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only IN (0, 1));
ALTER TABLE piece_usages ADD COLUMN presentation_image_only_snapshot INTEGER NOT NULL DEFAULT 0
CHECK (presentation_image_only_snapshot IN (0, 1));
ALTER TABLE show_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
ALTER TABLE show_template_sections ADD COLUMN twitch_tags TEXT NOT NULL DEFAULT '';
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.6.0", "version": "0.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.6.0", "version": "0.6.1",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.6.0", "version": "0.6.1",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+78
View File
@@ -328,6 +328,18 @@
object-fit: contain; object-fit: contain;
} }
.output-image-only {
display: grid;
height: 100%;
place-items: center;
}
.output-image-only img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shared-copy, .shared-copy,
.historical-usage section, .historical-usage section,
.historical-usage details { .historical-usage details {
@@ -4731,6 +4743,45 @@ textarea:focus {
gap: 0.5rem; gap: 0.5rem;
} }
.preparation-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.5rem;
}
.preparation-list a {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.preparation-list span {
display: grid;
min-width: 0;
}
.preparation-list small {
overflow: hidden;
color: var(--text-muted);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.effort {
padding: 0.2rem 0.45rem;
border-radius: 2rem;
background: var(--panel-strong);
color: var(--text-muted);
font-size: 0.62rem;
}
.show-card { .show-card {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6427,6 +6478,21 @@ textarea:focus {
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
input:not([type='checkbox']):not([type='radio']):not([type='hidden']),
select,
textarea,
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-popover {
display: grid;
max-height: calc(100dvh - var(--mobile-header) - var(--mobile-nav) - 1rem);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
}
.usage-editor-dialog { .usage-editor-dialog {
width: calc(100vw - 1rem); width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem); max-height: calc(100dvh - 1rem);
@@ -7361,6 +7427,18 @@ body:has(.heinzlive-output.empty),
calc(0.5rem + env(safe-area-inset-left)); calc(0.5rem + env(safe-area-inset-left));
width: auto; width: auto;
max-height: none; max-height: none;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.icon-picker-popover input[type='search'] {
font-size: 1rem;
}
.icon-picker-grid {
min-height: 0;
overflow-y: auto;
} }
.piece-subsection-heading .choice-picker-popover { .piece-subsection-heading .choice-picker-popover {
@@ -60,6 +60,11 @@
{/each} {/each}
</footer>{/if} </footer>{/if}
</article> </article>
{:else if content.mode === 'image'}
<article class="output-content output-image-only">
{#if content.topics.length}<p class="output-topics">{content.topics.join(' · ')}</p>{/if}
{#if imageSrc}<img src={imageSrc} alt={content.images?.[0]?.altText ?? content.title} />{/if}
</article>
{:else if content.mode === 'link'} {:else if content.mode === 'link'}
<article class="output-content output-link-card"> <article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image"> {#if imageSrc && !imageUnavailable}<div class="output-link-image">
+13 -2
View File
@@ -31,7 +31,7 @@
shows: ShareShow[]; shows: ShareShow[];
shares: ShareItem[]; shares: ShareItem[];
fixedShowId?: string; fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string; shareUrl?: string } | null; form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
} = $props(); } = $props();
let visibleShows = $derived( let visibleShows = $derived(
@@ -39,6 +39,7 @@
? shows.filter((show) => show.id === fixedShowId) ? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id)) : shows.filter((show) => shares.some((share) => share.showId === show.id))
); );
let dialog: HTMLDialogElement;
function germanDate(value: string | null, fallback = 'Termin offen') { function germanDate(value: string | null, fallback = 'Termin offen') {
return value return value
@@ -86,6 +87,10 @@
} }
</script> </script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
<AppIcon name="link" /> Lesefreigaben
</button>
<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'}`}
@@ -96,6 +101,9 @@
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2> <h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div> </div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span> <span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
<AppIcon name="xmark" />
</button>
</div> </div>
<p class="share-intro"> <p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
@@ -107,7 +115,7 @@
> >
{form.error} {form.error}
</div>{/if} </div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && form.success}<div {#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success" class="form-message success"
role="status" role="status"
> >
@@ -187,6 +195,7 @@
{:else} {:else}
<form class="share-edit-form" method="POST" action="?/updateShare"> <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} />
<label <label
>Informationsumfang<select name="scope" value={share.scope}> >Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option> <option value="rundown">Nur Ablauf</option>
@@ -231,6 +240,7 @@
}} }}
> >
<input type="hidden" name="shareId" value={share.id} /> <input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button> <button class="text-button danger-text" type="submit">Freigabe löschen</button>
</form> </form>
</div> </div>
@@ -247,3 +257,4 @@
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p> <p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if} {/if}
</section> </section>
</dialog>
+2 -2
View File
@@ -227,7 +227,7 @@
bind:value={draft.presentation} 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'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])} {#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label <label
><input ><input
type="radio" type="radio"
@@ -251,7 +251,7 @@
/> />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} /> <input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'} {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span> <span>Ausgewählte Quelle</span>
+8 -1
View File
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
sources: [ sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 } { label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
], ],
...overrides ...overrides,
preparationEffort: overrides.preparationEffort ?? null
}; };
} }
@@ -332,11 +333,17 @@ describe('content management', () => {
presentationMediaId: selected.id presentationMediaId: selected.id
}); });
savePiece(id, source.revision, {
...source,
status: 'preparing',
presentationMode: 'image'
});
const duplicateId = await duplicatePiece(id); const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!; const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1); expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id); expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id); expect(duplicate.presentationMediaId).not.toBe(selected.id);
expect(duplicate.presentationMode).toBe('image');
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten'); const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase() const snapshot = getDatabase()
+34 -12
View File
@@ -14,6 +14,7 @@ import {
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const; export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number]; export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>; export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type TaxonomyKind = 'category' | 'format' | 'topic'; export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = { export type PieceSourceInput = {
@@ -42,6 +43,7 @@ export type PieceInput = {
summary: string; summary: string;
formatId: string | null; formatId: string | null;
status: EditablePieceStatus; status: EditablePieceStatus;
preparationEffort: PreparationEffort;
durationSeconds: number; durationSeconds: number;
intro: string; intro: string;
researchText: string; researchText: string;
@@ -82,6 +84,7 @@ export type PieceListItem = {
sourceCount: number; sourceCount: number;
mediaId: string | null; mediaId: string | null;
isFavorite: boolean; isFavorite: boolean;
preparationEffort: PreparationEffort;
}; };
export type PieceEditor = Omit< export type PieceEditor = Omit<
@@ -198,6 +201,9 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
const title = cleanText(input.title, 200, 'title'); const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE'); if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS'); if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
if (![null, 'low', 'medium', 'high'].includes(input.preparationEffort)) {
throw new Error('INVALID_PREPARATION_EFFORT');
}
if ( if (
!Number.isInteger(input.durationSeconds) || !Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 || input.durationSeconds < 0 ||
@@ -226,7 +232,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
} }
return { label, url, timecodeSeconds: source.timecodeSeconds }; return { label, url, timecodeSeconds: source.timecodeSeconds };
}); });
if (!['text', 'link', 'video'].includes(input.presentationMode)) { if (!['text', 'image', 'link', 'video'].includes(input.presentationMode)) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
const sourcePosition = input.presentationSourcePosition; const sourcePosition = input.presentationSourcePosition;
@@ -236,7 +242,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
) { ) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
if (input.presentationMode !== 'text' && sourcePosition === null) { if (['link', 'video'].includes(input.presentationMode) && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image'); const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
@@ -249,12 +255,16 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
input.presentationMediaId === undefined input.presentationMediaId === undefined
? undefined ? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null; : cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
if (input.presentationMode === 'image' && !presentationMediaId) {
throw new Error('INVALID_PRESENTATION');
}
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'), outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'), summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null, formatId: input.formatId || null,
status: input.status, status: input.status,
preparationEffort: input.preparationEffort,
durationSeconds: input.durationSeconds, durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'), researchText: cleanText(input.researchText, 50_000, 'research'),
@@ -317,6 +327,7 @@ type PieceListRow = {
source_count: number; source_count: number;
media_id: string | null; media_id: string | null;
is_favorite: number; is_favorite: number;
preparation_effort: PreparationEffort;
}; };
export function listPieces(filters: { export function listPieces(filters: {
@@ -366,6 +377,7 @@ export function listPieces(filters: {
const rows = database const rows = database
.prepare( .prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite, `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.preparation_effort,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format, p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count, (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
@@ -398,7 +410,8 @@ export function listPieces(filters: {
topics: topicStatement.all(row.id) as PieceListItem['topics'], topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count, sourceCount: row.source_count,
mediaId: row.media_id, mediaId: row.media_id,
isFavorite: row.is_favorite === 1 isFavorite: row.is_favorite === 1,
preparationEffort: row.preparation_effort
})); }));
} }
@@ -433,14 +446,16 @@ type PieceRow = {
updated_at_utc: string; updated_at_utc: string;
is_favorite: number; is_favorite: number;
presentation_media_id: string | null; presentation_media_id: string | null;
preparation_effort: PreparationEffort;
}; };
export function getPiece(id: string): PieceEditor | null { export function getPiece(id: string): PieceEditor | null {
const database = getDatabase(); const database = getDatabase();
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, `SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text,
moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, moderation_notes, interaction_text, transition_text, presentation_text,
CASE WHEN presentation_image_only = 1 THEN 'image' ELSE presentation_mode END AS presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_provider_id, presentation_title_manual, presentation_description_manual,
@@ -460,6 +475,7 @@ export function getPiece(id: string): PieceEditor | null {
summary: row.summary, summary: row.summary,
formatId: row.format_id, formatId: row.format_id,
status: row.status, status: row.status,
preparationEffort: row.preparation_effort,
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
intro: row.intro, intro: row.intro,
researchText: row.research_text, researchText: row.research_text,
@@ -612,9 +628,9 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => { const save = database.transaction(() => {
const result = database const result = database
.prepare( .prepare(
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, preparation_effort = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?, intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_mode = ?, presentation_image_only = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?,
@@ -628,6 +644,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.summary, input.summary,
input.formatId, input.formatId,
input.status, input.status,
input.preparationEffort,
input.durationSeconds, input.durationSeconds,
input.intro, input.intro,
input.researchText, input.researchText,
@@ -635,7 +652,8 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.interactionText ?? null, input.interactionText ?? null,
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
input.presentationMode, input.presentationMode === 'image' ? 'text' : input.presentationMode,
input.presentationMode === 'image' ? 1 : 0,
input.presentationSourcePosition, input.presentationSourcePosition,
input.presentationTitle, input.presentationTitle,
input.presentationDescription, input.presentationDescription,
@@ -671,6 +689,7 @@ function blankPieceInput(): ValidatedPieceInput {
summary: '', summary: '',
formatId: null, formatId: null,
status: 'preparing', status: 'preparing',
preparationEffort: null,
durationSeconds: 0, durationSeconds: 0,
intro: '', intro: '',
researchText: '', researchText: '',
@@ -798,6 +817,7 @@ export function savePieceAsTemplate(
delete snapshot.updatedAtUtc; delete snapshot.updatedAtUtc;
delete snapshot.media; delete snapshot.media;
delete snapshot.presentationMediaId; delete snapshot.presentationMediaId;
if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text';
snapshot.status = 'preparing'; snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot); const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase(); const database = getDatabase();
@@ -917,15 +937,15 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro,
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode, presentation_image_only,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual, presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES ( VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)` )`
) )
@@ -935,6 +955,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.outputTitle, source.outputTitle,
source.summary, source.summary,
source.formatId, source.formatId,
source.preparationEffort,
source.durationSeconds, source.durationSeconds,
source.intro, source.intro,
source.researchText, source.researchText,
@@ -942,7 +963,8 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.interactionText, source.interactionText,
source.transitionText, source.transitionText,
source.presentationText, source.presentationText,
source.presentationMode, source.presentationMode === 'image' ? 'text' : source.presentationMode,
source.presentationMode === 'image' ? 1 : 0,
source.presentationSourcePosition, source.presentationSourcePosition,
source.presentationTitle, source.presentationTitle,
source.presentationDescription, source.presentationDescription,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 16, migrations: 17,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+2 -2
View File
@@ -40,7 +40,7 @@ afterEach(() => {
}); });
describe('live mode', () => { describe('live mode', () => {
it('persists a frozen section countdown across usage changes and reloads', () => { it('keeps elapsed section time while applying an updated section plan', () => {
getDatabase() getDatabase()
.prepare( .prepare(
"UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'" "UPDATE show_sections SET explicit_duration_seconds = 300 WHERE id = 'section-stories'"
@@ -68,7 +68,7 @@ describe('live mode', () => {
live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z')); live = getLiveView('show-october', new Date('2026-10-23T18:04:00.000Z'));
expect(live.current?.id).toBe('usage-effects'); expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({ expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300, plannedDurationSeconds: 600,
elapsedSeconds: 240 elapsedSeconds: 240
}); });
completeCurrent( completeCurrent(
+17 -4
View File
@@ -261,10 +261,22 @@ function syncSectionTimer(
if (!section) throw new Error('NOT_FOUND'); if (!section) throw new Error('NOT_FOUND');
const active = database const active = database
.prepare( .prepare(
'SELECT id, section_id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL' `SELECT id, section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
) )
.get(showId) as { id: string; section_id: string } | undefined; .get(showId) as
if (active?.section_id === section.id) return; | { id: string; section_id: string; planned_duration_seconds: number }
| undefined;
if (active?.section_id === section.id) {
if (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) {
closeSectionTimer(database, showId, now);
} else if (active.planned_duration_seconds !== section.explicit_duration_seconds) {
database
.prepare('UPDATE live_section_timers SET planned_duration_seconds = ? WHERE id = ?')
.run(section.explicit_duration_seconds, active.id);
}
return;
}
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 timerId = randomUUID(); const timerId = randomUUID();
@@ -1126,7 +1138,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
const usage = database const usage = database
.prepare( .prepare(
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
+1 -1
View File
@@ -8,7 +8,7 @@ import image from 'metascraper-image';
import publisher from 'metascraper-publisher'; import publisher from 'metascraper-publisher';
import title from 'metascraper-title'; import title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video'; export type PresentationMode = 'text' | 'image' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok'; export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = { export type PresentationMetadata = {
+61 -30
View File
@@ -27,6 +27,7 @@ export type ShowTemplateSection = {
categoryId: string | null; categoryId: string | null;
position: number; position: number;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags: string[];
}; };
export type ShowTemplate = { export type ShowTemplate = {
@@ -106,6 +107,7 @@ export type ShowSection = {
categoryColor: string | null; categoryColor: string | null;
position: number; position: number;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags: string[];
durationSeconds: number; durationSeconds: number;
usages: PieceUsage[]; usages: PieceUsage[];
}; };
@@ -144,6 +146,7 @@ export type ShowSectionInput = {
name: string; name: string;
categoryId: string | null; categoryId: string | null;
explicitDurationSeconds: number | null; explicitDurationSeconds: number | null;
twitchTags?: string[];
position?: number; position?: number;
}; };
@@ -207,6 +210,7 @@ type SectionRow = {
category_color_snapshot: string | null; category_color_snapshot: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number; is_reserve_bank: number;
}; };
@@ -226,6 +230,7 @@ type UsageRow = {
transition_snapshot: string; transition_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode; presentation_mode_snapshot: PresentationMode;
presentation_image_only_snapshot: number;
presentation_source_label_snapshot: string; presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string; presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null; presentation_source_timecode_snapshot: number | null;
@@ -254,6 +259,14 @@ function validDuration(value: number | null): number | null {
return value; return value;
} }
function validTwitchTags(values: string[]) {
const tags = [...new Set(values.map((tag) => tag.trim()).filter(Boolean))];
if (tags.length > 10 || tags.some((tag) => tag.length > 25 || tag.includes(','))) {
throw new Error('INVALID_TWITCH_TAGS');
}
return tags;
}
function validPosition(value: number, maximum: number): number { function validPosition(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION'); if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum); return Math.min(value, maximum);
@@ -277,7 +290,7 @@ function validPresentation(
imageUrl: string, imageUrl: string,
siteName: string siteName: string
) { ) {
if (!['text', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION'); if (!['text', 'image', 'link', 'video'].includes(mode)) throw new Error('INVALID_PRESENTATION');
const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source'); const cleanUrl = cleanText(sourceUrl, 2_000, 'presentation_source');
const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image'); const cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) { for (const value of [cleanUrl, cleanImageUrl]) {
@@ -289,7 +302,7 @@ function validPresentation(
throw new Error('INVALID_PRESENTATION'); throw new Error('INVALID_PRESENTATION');
} }
} }
if (mode !== 'text' && !cleanUrl) throw new Error('INVALID_PRESENTATION'); if ((mode === 'link' || mode === 'video') && !cleanUrl) throw new Error('INVALID_PRESENTATION');
if ( if (
timecodeSeconds !== null && timecodeSeconds !== null &&
(!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400) (!Number.isInteger(timecodeSeconds) || timecodeSeconds < 0 || timecodeSeconds > 86_400)
@@ -762,7 +775,7 @@ export function listShowTemplates(): ShowTemplate[] {
.prepare('SELECT id, name, description FROM show_templates ORDER BY name') .prepare('SELECT id, name, description FROM show_templates ORDER BY name')
.all() as Omit<ShowTemplate, 'sections'>[]; .all() as Omit<ShowTemplate, 'sections'>[];
const sectionStatement = database.prepare( const sectionStatement = database.prepare(
`SELECT id, name, category_id, position, explicit_duration_seconds `SELECT id, name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position` FROM show_template_sections WHERE template_id = ? ORDER BY position`
); );
return templates.map((template) => ({ return templates.map((template) => ({
@@ -774,13 +787,15 @@ export function listShowTemplates(): ShowTemplate[] {
category_id: string | null; category_id: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
}[] }[]
).map((section) => ({ ).map((section) => ({
id: section.id, id: section.id,
name: section.name, name: section.name,
categoryId: section.category_id, categoryId: section.category_id,
position: section.position, position: section.position,
explicitDurationSeconds: section.explicit_duration_seconds explicitDurationSeconds: section.explicit_duration_seconds,
twitchTags: section.twitch_tags ? section.twitch_tags.split(',') : []
})) }))
})); }));
} }
@@ -798,7 +813,7 @@ export function getShow(id: string): Show | null {
.prepare( .prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color, `SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.category_name_snapshot, s.category_color_snapshot, s.category_name_snapshot, s.category_color_snapshot,
s.position, s.explicit_duration_seconds, s.is_reserve_bank s.position, s.explicit_duration_seconds, s.twitch_tags, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position` WHERE s.show_id = ? ORDER BY s.position`
) )
@@ -808,7 +823,9 @@ export function getShow(id: string): Show | null {
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot, u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, CASE WHEN u.presentation_image_only_snapshot = 1 THEN 'image' ELSE u.presentation_mode_snapshot END AS presentation_mode_snapshot,
u.presentation_image_only_snapshot,
u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
u.presentation_image_url_snapshot, u.presentation_site_name_snapshot, u.presentation_image_url_snapshot, u.presentation_site_name_snapshot,
@@ -841,6 +858,7 @@ export function getShow(id: string): Show | null {
: (row.category_color_snapshot ?? row.category_color), : (row.category_color_snapshot ?? row.category_color),
position: row.position, position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds, explicitDurationSeconds: row.explicit_duration_seconds,
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
durationSeconds: durationSeconds:
row.explicit_duration_seconds ?? row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0), usages.reduce((total, usage) => total + usage.durationSeconds, 0),
@@ -957,7 +975,7 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
const templateSections = input.templateId const templateSections = input.templateId
? (database ? (database
.prepare( .prepare(
`SELECT name, category_id, position, explicit_duration_seconds `SELECT name, category_id, position, explicit_duration_seconds, twitch_tags
FROM show_template_sections WHERE template_id = ? ORDER BY position` FROM show_template_sections WHERE template_id = ? ORDER BY position`
) )
.all(input.templateId) as { .all(input.templateId) as {
@@ -965,11 +983,12 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
category_id: string | null; category_id: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
}[]) }[])
: []; : [];
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)` explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
); );
for (const section of templateSections) { for (const section of templateSections) {
insertSection.run( insertSection.run(
@@ -979,10 +998,11 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
section.category_id, section.category_id,
section.position, section.position,
section.explicit_duration_seconds, section.explicit_duration_seconds,
section.twitch_tags,
0 0
); );
} }
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1); insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
}); });
create(); create();
return { id, revision: 1, updatedAtUtc: now }; return { id, revision: 1, updatedAtUtc: now };
@@ -1033,7 +1053,7 @@ export function duplicateCompletedShow(
const sections = database const sections = database
.prepare( .prepare(
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot, `SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
position, explicit_duration_seconds, is_reserve_bank position, explicit_duration_seconds, twitch_tags, is_reserve_bank
FROM show_sections WHERE show_id = ? ORDER BY position` FROM show_sections WHERE show_id = ? ORDER BY position`
) )
.all(sourceShowId) as { .all(sourceShowId) as {
@@ -1044,12 +1064,13 @@ export function duplicateCompletedShow(
category_color_snapshot: string | null; category_color_snapshot: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number; is_reserve_bank: number;
}[]; }[];
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot, `INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, is_reserve_bank) category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
const usages = database.prepare( const usages = database.prepare(
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position' 'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
@@ -1062,8 +1083,8 @@ export function duplicateCompletedShow(
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
for (const section of sections) { for (const section of sections) {
const sectionId = randomUUID(); const sectionId = randomUUID();
@@ -1076,6 +1097,7 @@ export function duplicateCompletedShow(
section.category_color_snapshot, section.category_color_snapshot,
section.position, section.position,
section.explicit_duration_seconds, section.explicit_duration_seconds,
section.twitch_tags,
section.is_reserve_bank section.is_reserve_bank
); );
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) { for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
@@ -1104,6 +1126,7 @@ export function duplicateCompletedShow(
usage.presentation_site_name_snapshot, usage.presentation_site_name_snapshot,
usage.presentation_provider_snapshot, usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot, usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.own_notes, usage.own_notes,
usage.is_reserve usage.is_reserve
); );
@@ -1150,6 +1173,7 @@ export function addShowSection(
): CreatedMutationResult { ): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true); const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds); const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase(); const database = getDatabase();
requireCategory(database, input.categoryId); requireCategory(database, input.categoryId);
return database.transaction(() => { return database.transaction(() => {
@@ -1174,9 +1198,9 @@ export function addShowSection(
database database
.prepare( .prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)` explicit_duration_seconds, twitch_tags, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?, 0)`
) )
.run(id, showId, name, input.categoryId, temporaryPosition, duration); .run(id, showId, name, input.categoryId, temporaryPosition, duration, twitchTags.join(','));
visibleIds.splice(position, 0, id); visibleIds.splice(position, 0, id);
const reserveId = reserveSectionId(database, showId); const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]); normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
@@ -1192,6 +1216,7 @@ export function updateShowSection(
): MutationResult { ): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true); const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds); const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase(); const database = getDatabase();
requireCategory(database, input.categoryId); requireCategory(database, input.categoryId);
return database.transaction(() => { return database.transaction(() => {
@@ -1199,11 +1224,11 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
database database
.prepare( .prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, `UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?, twitch_tags = ?,
category_name_snapshot = NULL, category_color_snapshot = NULL category_name_snapshot = NULL, category_color_snapshot = NULL
WHERE id = ?` WHERE id = ?`
) )
.run(name, input.categoryId, duration, sectionId); .run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
return result; return result;
})(); })();
} }
@@ -1275,7 +1300,8 @@ export function addPieceUsage(
const piece = database const piece = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.interaction_text, p.transition_text, p.presentation_text, p.status, p.presentation_mode, p.interaction_text, p.transition_text, p.presentation_text, p.status,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -1329,12 +1355,12 @@ export function addPieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -1350,7 +1376,8 @@ export function addPieceUsage(
piece.interaction_text, piece.interaction_text,
piece.transition_text, piece.transition_text,
piece.presentation_text, piece.presentation_text,
piece.presentation_mode, piece.presentation_mode === 'image' ? 'text' : piece.presentation_mode,
piece.presentation_mode === 'image' ? 1 : 0,
piece.presentation_source_label, piece.presentation_source_label,
piece.presentation_source_url, piece.presentation_source_url,
piece.presentation_source_timecode, piece.presentation_source_timecode,
@@ -1436,7 +1463,7 @@ export function duplicatePieceUsage(
.prepare( .prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, `SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
@@ -1459,12 +1486,12 @@ export function duplicatePieceUsage(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_image_only_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -1481,6 +1508,7 @@ export function duplicatePieceUsage(
source.transition_snapshot, source.transition_snapshot,
source.presentation_snapshot, source.presentation_snapshot,
source.presentation_mode_snapshot, source.presentation_mode_snapshot,
source.presentation_image_only_snapshot,
source.presentation_source_label_snapshot, source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot, source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot, source.presentation_source_timecode_snapshot,
@@ -1512,7 +1540,8 @@ export function refreshPieceUsageFromOriginal(
const source = database const source = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.presentation_mode, p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text,
CASE WHEN p.presentation_image_only = 1 THEN 'image' ELSE p.presentation_mode END AS presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -1551,7 +1580,7 @@ export function refreshPieceUsageFromOriginal(
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?, presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
@@ -1568,7 +1597,8 @@ export function refreshPieceUsageFromOriginal(
source.interaction_text, source.interaction_text,
source.transition_text, source.transition_text,
source.presentation_text, source.presentation_text,
source.presentation_mode, source.presentation_mode === 'image' ? 'text' : source.presentation_mode,
source.presentation_mode === 'image' ? 1 : 0,
source.presentation_source_label, source.presentation_source_label,
source.presentation_source_url, source.presentation_source_url,
source.presentation_source_timecode, source.presentation_source_timecode,
@@ -1630,7 +1660,7 @@ export function updatePieceUsage(
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_mode_snapshot = ?, presentation_image_only_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?, presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
@@ -1646,7 +1676,8 @@ export function updatePieceUsage(
values.interaction, values.interaction,
values.transition, values.transition,
values.presentation, values.presentation,
presentation.mode, presentation.mode === 'image' ? 'text' : presentation.mode,
presentation.mode === 'image' ? 1 : 0,
presentation.sourceLabel, presentation.sourceLabel,
presentation.sourceUrl, presentation.sourceUrl,
presentation.timecodeSeconds, presentation.timecodeSeconds,
+1
View File
@@ -75,6 +75,7 @@ function pieceInput(data: FormData): PieceInput {
summary: text(data, 'summary'), summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null, formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus, status: text(data, 'status') as EditablePieceStatus,
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
durationSeconds: durationInSeconds(data), durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'), intro: text(data, 'intro'),
researchText: text(data, 'researchText'), researchText: text(data, 'researchText'),
+8 -1
View File
@@ -347,6 +347,13 @@
bind:value={durationMinutes} bind:value={durationMinutes}
/></label /></label
> >
<label
>Vorbereitungsaufwand<select name="preparationEffort" bind:value={draft.preparationEffort}
><option value={null}>Nicht angegeben</option><option value="low">Gering</option><option
value="medium">Mittel</option
><option value="high">Hoch</option></select
></label
>
<label <label
>Format<select name="formatId" bind:value={draft.formatId} >Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option ><option value={null}>Ohne Format</option
@@ -542,7 +549,7 @@
bind:value={draft.presentationText} 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'], ['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
type="radio" type="radio"
name="presentationMode" name="presentationMode"
+45 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js'; import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
import { import {
completeCurrent, completeCurrent,
@@ -25,6 +25,8 @@ 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 { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) { function text(data: FormData, name: string) {
return String(data.get(name) || ''); return String(data.get(name) || '');
@@ -71,13 +73,15 @@ function attempt(intent: string, operation: () => unknown) {
} }
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Keine Sendung vorhanden');
try { try {
const live = getLiveView(showId); const live = getLiveView(showId);
return { return {
live, live,
canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(), commandId: randomUUID(),
completion: completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
@@ -90,6 +94,45 @@ export const load: PageServerLoad = ({ locals, url }) => {
}; };
export const actions = { export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createShare', ...problem });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, text(data, 'shareId'), {
scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
}, text(data, 'showId'));
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'updateShare', ...problem });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), text(data, 'showId'));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'deleteShare', ...problem });
}
},
start: async ({ locals, request }) => { start: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+18 -1
View File
@@ -4,6 +4,7 @@
import { onMount, tick, untrack } from 'svelte'; import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -49,6 +50,9 @@
let currentSection = $derived( let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
); );
let viewedSection = $derived(
data.live.sections.find((section) => section.id === viewedUsage?.sectionId) ?? null
);
let presentedUsage = $derived( let presentedUsage = $derived(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
); );
@@ -101,6 +105,16 @@
data.live.sectionTimer.sectionId === data.live.current.sectionId data.live.sectionTimer.sectionId === data.live.current.sectionId
) )
); );
async function copyTwitchTags() {
if (!viewedSection?.twitchTags.length) return;
try {
await navigator.clipboard.writeText(viewedSection.twitchTags.join(', '));
popupMessage = 'Twitch-Tags kopiert.';
} catch {
popupMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
let runningOffsetSeconds = $derived( let runningOffsetSeconds = $derived(
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0 data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
); );
@@ -577,6 +591,7 @@
</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}
<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>
@@ -794,12 +809,14 @@
> >
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}> <div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading"> <div class="live-piece-heading">
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1> <h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time"> {#if showTimes}<strong class="live-piece-time">
{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}>
<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>
+2
View File
@@ -3,6 +3,7 @@ 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 { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { listPieces } from '#lib/server/content.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 }) => {
@@ -12,6 +13,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'] }),
templates: listShowTemplates(), templates: listShowTemplates(),
view: getShowOverviewView(user.id), view: getShowOverviewView(user.id),
selectedMonth, selectedMonth,
+10
View File
@@ -125,6 +125,16 @@
</form> </form>
</details> </details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
<div class="preparation-list">
{#each data.preparationPieces as piece (piece.id)}<a href={resolve('/fundus/[id]', { id: piece.id })}>
<span><strong>{piece.title}</strong>{#if piece.summary}<small>{piece.summary}</small>{/if}</span>
{#if piece.preparationEffort}<b class={`effort ${piece.preparationEffort}`}>{piece.preparationEffort === 'low' ? 'Gering' : piece.preparationEffort === 'medium' ? 'Mittel' : 'Hoch'}</b>{/if}
</a>{/each}
</div>
</section>{/if}
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <header>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2> <h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
+4 -2
View File
@@ -233,7 +233,8 @@ export const actions = {
addShowSection(params.id, Number(data.get('revision')), { addShowSection(params.id, Number(data.get('revision')), {
name: text(data, 'name'), name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null, categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data) explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
}) })
); );
}, },
@@ -244,7 +245,8 @@ export const actions = {
updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), { updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), {
name: text(data, 'name'), name: text(data, 'name'),
categoryId: text(data, 'categoryId') || null, categoryId: text(data, 'categoryId') || null,
explicitDurationSeconds: nullableDuration(data) explicitDurationSeconds: nullableDuration(data),
twitchTags: text(data, 'twitchTags').split(/[\n,]/)
}) })
); );
}, },
+9
View File
@@ -828,6 +828,13 @@
value={section.explicitDurationSeconds ?? ''} value={section.explicitDurationSeconds ?? ''}
placeholder="Summe der Pieces" placeholder="Summe der Pieces"
/></label /></label
><label
>Twitch-Tags<input
name="twitchTags"
maxlength="300"
value={section.twitchTags.join(', ')}
placeholder="Gaming, Deutsch"
/></label
><button class="secondary-button" type="submit">Abschnitt speichern</button> ><button class="secondary-button" type="submit">Abschnitt speichern</button>
</form> </form>
<form <form
@@ -885,6 +892,8 @@
max="86400" max="86400"
placeholder="optional, Sekunden" placeholder="optional, Sekunden"
/></label /></label
><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>
+2
View File
@@ -1665,6 +1665,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
addCompletionFixture(); addCompletionFixture();
try { try {
await page.goto('/sendungen/e2e-completion'); await page.goto('/sendungen/e2e-completion');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const planningShares = page.locator('.share-management'); const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible(); await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation'); await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
@@ -1738,6 +1739,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await page.goto('/verwaltung'); await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management'); const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion'); await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation'); await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');