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
+398 -66

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.6.0",
"version": "0.6.1",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+21
View File
@@ -1,5 +1,26 @@
# 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
| 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
Präsentationsmetadaten und Vorschaubilder schützen interne Ziele auch nach
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
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
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
## 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
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,
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
- 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",
"version": "0.6.0",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.6.0",
"version": "0.6.1",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.6.0",
"version": "0.6.1",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+78
View File
@@ -328,6 +328,18 @@
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,
.historical-usage section,
.historical-usage details {
@@ -4731,6 +4743,45 @@ textarea:focus {
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 {
display: flex;
align-items: center;
@@ -6427,6 +6478,21 @@ textarea:focus {
flex-direction: column;
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 {
width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
@@ -7361,6 +7427,18 @@ body:has(.heinzlive-output.empty),
calc(0.5rem + env(safe-area-inset-left));
width: auto;
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 {
@@ -60,6 +60,11 @@
{/each}
</footer>{/if}
</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'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
+14 -3
View File
@@ -31,7 +31,7 @@
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string; shareUrl?: string } | null;
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
} = $props();
let visibleShows = $derived(
@@ -39,6 +39,7 @@
? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
@@ -86,6 +87,10 @@
}
</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
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
@@ -96,6 +101,9 @@
<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>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
@@ -107,7 +115,7 @@
>
{form.error}
</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"
role="status"
>
@@ -187,6 +195,7 @@
{: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>
@@ -230,7 +239,8 @@
event.preventDefault();
}}
>
<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>
</form>
</div>
@@ -247,3 +257,4 @@
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</dialog>
+2 -2
View File
@@ -227,7 +227,7 @@
bind:value={draft.presentation}
/>
<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
><input
type="radio"
@@ -251,7 +251,7 @@
/>
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<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-source-summary wide-field">
<span>Ausgewählte Quelle</span>
+8 -1
View File
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
sources: [
{ 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
});
savePiece(id, source.revision, {
...source,
status: 'preparing',
presentationMode: 'image'
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
expect(duplicate.presentationMode).toBe('image');
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
+34 -12
View File
@@ -14,6 +14,7 @@ import {
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type PreparationEffort = 'low' | 'medium' | 'high' | null;
export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = {
@@ -42,6 +43,7 @@ export type PieceInput = {
summary: string;
formatId: string | null;
status: EditablePieceStatus;
preparationEffort: PreparationEffort;
durationSeconds: number;
intro: string;
researchText: string;
@@ -82,6 +84,7 @@ export type PieceListItem = {
sourceCount: number;
mediaId: string | null;
isFavorite: boolean;
preparationEffort: PreparationEffort;
};
export type PieceEditor = Omit<
@@ -198,6 +201,9 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
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 (
!Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 ||
@@ -226,7 +232,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
}
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');
}
const sourcePosition = input.presentationSourcePosition;
@@ -236,7 +242,7 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
) {
throw new Error('INVALID_PRESENTATION');
}
if (input.presentationMode !== 'text' && sourcePosition === null) {
if (['link', 'video'].includes(input.presentationMode) && sourcePosition === null) {
throw new Error('INVALID_PRESENTATION');
}
const presentationImageUrl = cleanText(input.presentationImageUrl, 2_000, 'presentation_image');
@@ -249,12 +255,16 @@ function validatePieceInput(input: PieceInput): ValidatedPieceInput {
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
if (input.presentationMode === 'image' && !presentationMediaId) {
throw new Error('INVALID_PRESENTATION');
}
return {
title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
preparationEffort: input.preparationEffort,
durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'),
@@ -317,6 +327,7 @@ type PieceListRow = {
source_count: number;
media_id: string | null;
is_favorite: number;
preparation_effort: PreparationEffort;
};
export function listPieces(filters: {
@@ -366,6 +377,7 @@ export function listPieces(filters: {
const rows = database
.prepare(
`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,
(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
@@ -398,7 +410,8 @@ export function listPieces(filters: {
topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count,
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;
is_favorite: number;
presentation_media_id: string | null;
preparation_effort: PreparationEffort;
};
export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
`SELECT id, title, output_title, summary, format_id, status, preparation_effort, duration_seconds, intro, research_text,
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_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
@@ -460,6 +475,7 @@ export function getPiece(id: string): PieceEditor | null {
summary: row.summary,
formatId: row.format_id,
status: row.status,
preparationEffort: row.preparation_effort,
durationSeconds: row.duration_seconds,
intro: row.intro,
researchText: row.research_text,
@@ -612,9 +628,9 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const save = database.transaction(() => {
const result = database
.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 = ?,
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_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
presentation_description_manual = ?, presentation_image_manual = ?,
@@ -628,6 +644,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.summary,
input.formatId,
input.status,
input.preparationEffort,
input.durationSeconds,
input.intro,
input.researchText,
@@ -635,7 +652,8 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.interactionText ?? null,
input.transitionText,
input.presentationText,
input.presentationMode,
input.presentationMode === 'image' ? 'text' : input.presentationMode,
input.presentationMode === 'image' ? 1 : 0,
input.presentationSourcePosition,
input.presentationTitle,
input.presentationDescription,
@@ -671,6 +689,7 @@ function blankPieceInput(): ValidatedPieceInput {
summary: '',
formatId: null,
status: 'preparing',
preparationEffort: null,
durationSeconds: 0,
intro: '',
researchText: '',
@@ -798,6 +817,7 @@ export function savePieceAsTemplate(
delete snapshot.updatedAtUtc;
delete snapshot.media;
delete snapshot.presentationMediaId;
if (snapshot.presentationMode === 'image') snapshot.presentationMode = 'text';
snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase();
@@ -917,15 +937,15 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
`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, presentation_image_only,
presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)`
)
@@ -935,6 +955,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.outputTitle,
source.summary,
source.formatId,
source.preparationEffort,
source.durationSeconds,
source.intro,
source.researchText,
@@ -942,7 +963,8 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.interactionText,
source.transitionText,
source.presentationText,
source.presentationMode,
source.presentationMode === 'image' ? 'text' : source.presentationMode,
source.presentationMode === 'image' ? 1 : 0,
source.presentationSourcePosition,
source.presentationTitle,
source.presentationDescription,
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 16,
migrations: 17,
pieces: 6,
shows: 2,
usages: 5,
+2 -2
View File
@@ -40,7 +40,7 @@ afterEach(() => {
});
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()
.prepare(
"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'));
expect(live.current?.id).toBe('usage-effects');
expect(live.sectionTimer).toMatchObject({
plannedDurationSeconds: 300,
plannedDurationSeconds: 600,
elapsedSeconds: 240
});
completeCurrent(
+17 -4
View File
@@ -261,10 +261,22 @@ function syncSectionTimer(
if (!section) throw new Error('NOT_FOUND');
const active = database
.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;
if (active?.section_id === section.id) return;
.get(showId) as
| { 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 (!section.explicit_duration_seconds || section.explicit_duration_seconds <= 0) return;
const timerId = randomUUID();
@@ -1126,7 +1138,8 @@ function getPresentationViewForShow(showId: string): PresentationView {
const usage = database
.prepare(
`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_title_meta_snapshot, u.presentation_description_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 title from 'metascraper-title';
export type PresentationMode = 'text' | 'link' | 'video';
export type PresentationMode = 'text' | 'image' | 'link' | 'video';
export type VideoProvider = 'youtube' | 'tiktok';
export type PresentationMetadata = {
+61 -30
View File
@@ -27,6 +27,7 @@ export type ShowTemplateSection = {
categoryId: string | null;
position: number;
explicitDurationSeconds: number | null;
twitchTags: string[];
};
export type ShowTemplate = {
@@ -106,6 +107,7 @@ export type ShowSection = {
categoryColor: string | null;
position: number;
explicitDurationSeconds: number | null;
twitchTags: string[];
durationSeconds: number;
usages: PieceUsage[];
};
@@ -144,6 +146,7 @@ export type ShowSectionInput = {
name: string;
categoryId: string | null;
explicitDurationSeconds: number | null;
twitchTags?: string[];
position?: number;
};
@@ -207,6 +210,7 @@ type SectionRow = {
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number;
};
@@ -226,6 +230,7 @@ type UsageRow = {
transition_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
presentation_image_only_snapshot: number;
presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string;
presentation_source_timecode_snapshot: number | null;
@@ -254,6 +259,14 @@ function validDuration(value: number | null): number | null {
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 {
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
return Math.min(value, maximum);
@@ -277,7 +290,7 @@ function validPresentation(
imageUrl: 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 cleanImageUrl = cleanText(imageUrl, 2_000, 'presentation_image');
for (const value of [cleanUrl, cleanImageUrl]) {
@@ -289,7 +302,7 @@ function validPresentation(
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 (
timecodeSeconds !== null &&
(!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')
.all() as Omit<ShowTemplate, 'sections'>[];
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`
);
return templates.map((template) => ({
@@ -774,13 +787,15 @@ export function listShowTemplates(): ShowTemplate[] {
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
}[]
).map((section) => ({
id: section.id,
name: section.name,
categoryId: section.category_id,
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(
`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.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
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,
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.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_title_meta_snapshot, u.presentation_description_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),
position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds,
twitchTags: row.twitch_tags ? row.twitch_tags.split(',') : [],
durationSeconds:
row.explicit_duration_seconds ??
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
@@ -957,7 +975,7 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
const templateSections = input.templateId
? (database
.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`
)
.all(input.templateId) as {
@@ -965,11 +983,12 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
category_id: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
}[])
: [];
const insertSection = database.prepare(
`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) {
insertSection.run(
@@ -979,10 +998,11 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
section.category_id,
section.position,
section.explicit_duration_seconds,
section.twitch_tags,
0
);
}
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, '', 1);
});
create();
return { id, revision: 1, updatedAtUtc: now };
@@ -1033,7 +1053,7 @@ export function duplicateCompletedShow(
const sections = database
.prepare(
`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`
)
.all(sourceShowId) as {
@@ -1044,12 +1064,13 @@ export function duplicateCompletedShow(
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
twitch_tags: string;
is_reserve_bank: number;
}[];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
category_color_snapshot, position, explicit_duration_seconds, twitch_tags, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const usages = database.prepare(
'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_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
presentation_provider_snapshot, presentation_provider_id_snapshot, presentation_image_only_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
@@ -1076,6 +1097,7 @@ export function duplicateCompletedShow(
section.category_color_snapshot,
section.position,
section.explicit_duration_seconds,
section.twitch_tags,
section.is_reserve_bank
);
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_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.presentation_image_only_snapshot,
usage.own_notes,
usage.is_reserve
);
@@ -1150,6 +1173,7 @@ export function addShowSection(
): CreatedMutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
@@ -1174,9 +1198,9 @@ export function addShowSection(
database
.prepare(
`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);
const reserveId = reserveSectionId(database, showId);
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
@@ -1192,6 +1216,7 @@ export function updateShowSection(
): MutationResult {
const name = cleanText(input.name, 200, 'section_name', true);
const duration = validDuration(input.explicitDurationSeconds);
const twitchTags = validTwitchTags(input.twitchTags ?? []);
const database = getDatabase();
requireCategory(database, input.categoryId);
return database.transaction(() => {
@@ -1199,11 +1224,11 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId);
database
.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
WHERE id = ?`
)
.run(name, input.categoryId, duration, sectionId);
.run(name, input.categoryId, duration, twitchTags.join(','), sectionId);
return result;
})();
}
@@ -1275,7 +1300,8 @@ export function addPieceUsage(
const piece = database
.prepare(
`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.url, '') AS presentation_source_url,
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,
duration_seconds, title_snapshot, output_title_snapshot, intro_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_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -1350,7 +1376,8 @@ export function addPieceUsage(
piece.interaction_text,
piece.transition_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_url,
piece.presentation_source_timecode,
@@ -1436,7 +1463,7 @@ export function duplicatePieceUsage(
.prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_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_title_meta_snapshot, presentation_description_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,
duration_seconds, title_snapshot, output_title_snapshot, intro_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_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -1481,6 +1508,7 @@ export function duplicatePieceUsage(
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
source.presentation_image_only_snapshot,
source.presentation_source_label_snapshot,
source.presentation_source_url_snapshot,
source.presentation_source_timecode_snapshot,
@@ -1512,7 +1540,8 @@ export function refreshPieceUsageFromOriginal(
const source = database
.prepare(
`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.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
@@ -1551,7 +1580,7 @@ export function refreshPieceUsageFromOriginal(
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_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_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
presentation_description_snapshot = ?, presentation_image_url_snapshot = ?,
@@ -1568,7 +1597,8 @@ export function refreshPieceUsageFromOriginal(
source.interaction_text,
source.transition_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_url,
source.presentation_source_timecode,
@@ -1630,7 +1660,7 @@ export function updatePieceUsage(
.prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_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_title_meta_snapshot = ?, presentation_description_snapshot = ?,
presentation_image_url_snapshot = ?, presentation_site_name_snapshot = ?,
@@ -1646,7 +1676,8 @@ export function updatePieceUsage(
values.interaction,
values.transition,
values.presentation,
presentation.mode,
presentation.mode === 'image' ? 'text' : presentation.mode,
presentation.mode === 'image' ? 1 : 0,
presentation.sourceLabel,
presentation.sourceUrl,
presentation.timecodeSeconds,
+1
View File
@@ -75,6 +75,7 @@ function pieceInput(data: FormData): PieceInput {
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
durationSeconds: durationInSeconds(data),
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
+8 -1
View File
@@ -347,6 +347,13 @@
bind:value={durationMinutes}
/></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
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
@@ -542,7 +549,7 @@
bind:value={draft.presentationText}
/>
<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
type="radio"
name="presentationMode"
+45 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
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 {
completeCurrent,
@@ -25,6 +25,8 @@ import {
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
function text(data: FormData, name: string) {
return String(data.get(name) || '');
@@ -71,13 +73,15 @@ function attempt(intent: string, operation: () => unknown) {
}
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const actor = requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
const live = getLiveView(showId);
return {
live,
canManageShares: actor.role === 'barkeeper',
shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [],
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
@@ -90,6 +94,45 @@ export const load: PageServerLoad = ({ locals, url }) => {
};
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 }) => {
requireUser(locals.user);
const data = await request.formData();
+18 -1
View File
@@ -4,6 +4,7 @@
import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
@@ -49,6 +50,9 @@
let currentSection = $derived(
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(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
);
@@ -101,6 +105,16 @@
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(
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
);
@@ -577,6 +591,7 @@
</section>
<section>
<strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement shows={[{ ...data.live.show, startsAtUtc: null }]} shares={data.shares} fixedShowId={data.live.show.id} {form} />{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
@@ -794,12 +809,14 @@
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if viewedSection?.twitchTags.length}<button class="text-button live-twitch-tags" type="button" onclick={copyTwitchTags}>
<AppIcon name="copy" /> {viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<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 { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
import { listPieces } from '#lib/server/content.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => {
@@ -12,6 +13,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(['draft', 'ready', 'live']),
preparationPieces: listPieces({ statuses: ['preparing'] }),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
+10
View File
@@ -125,6 +125,16 @@
</form>
</details>
{#if data.preparationPieces.length}<section class="show-library preparation-overview" aria-labelledby="preparation-title">
<header><h2 id="preparation-title">Noch vorzubereiten</h2><span>{data.preparationPieces.length}</span></header>
<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">
<header>
<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')), {
name: text(data, 'name'),
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')), {
name: text(data, 'name'),
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 ?? ''}
placeholder="Summe der Pieces"
/></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>
</form>
<form
@@ -885,6 +892,8 @@
max="86400"
placeholder="optional, Sekunden"
/></label
><label
>Twitch-Tags<input name="twitchTags" maxlength="300" placeholder="Gaming, Deutsch" /></label
><button class="primary-button" type="submit">Abschnitt hinzufügen</button>
</form>
+2
View File
@@ -1665,6 +1665,7 @@ test('completion, archive and protected guest sharing work end to end', async ({
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
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 page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');