+ {#if content.topics.length}{content.topics.join(' · ')}
{/if}
+ {#if imageSrc} {/if}
+
{:else if content.mode === 'link'}
{#if imageSrc && !imageUnavailable}
diff --git a/src/lib/components/ShareManagement.svelte b/src/lib/components/ShareManagement.svelte
index 2d92b44..55296ae 100644
--- a/src/lib/components/ShareManagement.svelte
+++ b/src/lib/components/ShareManagement.svelte
@@ -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 @@
}
+
dialog.showModal()}>
+ Lesefreigaben
+
+
Aktualisiert sich bis zum Abschluss
+ dialog.close()}>
+
+
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
@@ -107,7 +115,7 @@
>
{form.error}
{/if}
- {#if form?.intent?.toLowerCase().includes('share') && form.success}
@@ -187,6 +195,7 @@
{:else}
@@ -247,3 +257,4 @@
Noch keine Lesefreigaben angelegt.
{/if}
+
diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte
index 1ecdbfb..cd65270 100644
--- a/src/lib/components/UsageEditorDialog.svelte
+++ b/src/lib/components/UsageEditorDialog.svelte
@@ -227,7 +227,7 @@
bind:value={draft.presentation}
/>
- {#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])}
- {#if draft.presentationMode !== 'text'}
+ {#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
Ausgewählte Quelle
diff --git a/src/lib/server/content.spec.ts b/src/lib/server/content.spec.ts
index ab2220f..cfed35d 100644
--- a/src/lib/server/content.spec.ts
+++ b/src/lib/server/content.spec.ts
@@ -74,7 +74,8 @@ function completeInput(overrides: Partial
= {}): 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()
diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts
index cca99d9..01aa54e 100644
--- a/src/lib/server/content.ts
+++ b/src/lib/server/content.ts
@@ -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;
+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 {
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 {
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 {
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,
diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts
index de4eed0..31019e9 100644
--- a/src/lib/server/database.spec.ts
+++ b/src/lib/server/database.spec.ts
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
- migrations: 16,
+ migrations: 17,
pieces: 6,
shows: 2,
usages: 5,
diff --git a/src/lib/server/live.spec.ts b/src/lib/server/live.spec.ts
index 16ed76d..a85eb09 100644
--- a/src/lib/server/live.spec.ts
+++ b/src/lib/server/live.spec.ts
@@ -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(
diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts
index 34e3d0c..bc3b132 100644
--- a/src/lib/server/live.ts
+++ b/src/lib/server/live.ts
@@ -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,
diff --git a/src/lib/server/presentation.ts b/src/lib/server/presentation.ts
index 87e9f80..e013587 100644
--- a/src/lib/server/presentation.ts
+++ b/src/lib/server/presentation.ts
@@ -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 = {
diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts
index c64418a..56c9989 100644
--- a/src/lib/server/shows.ts
+++ b/src/lib/server/shows.ts
@@ -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[];
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,
diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts
index f02eca2..1be3b5b 100644
--- a/src/routes/fundus/[id]/+page.server.ts
+++ b/src/routes/fundus/[id]/+page.server.ts
@@ -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'),
diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte
index 85495b1..a42f058 100644
--- a/src/routes/fundus/[id]/+page.svelte
+++ b/src/routes/fundus/[id]/+page.svelte
@@ -347,6 +347,13 @@
bind:value={durationMinutes}
/>
+ VorbereitungsaufwandNicht angegeben Gering Mittel Hoch
FormatOhne Format
- {#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
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();
diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte
index a71de84..5974d21 100644
--- a/src/routes/live/+page.svelte
+++ b/src/routes/live/+page.svelte
@@ -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 @@
Ausgabekanal
+ {#if data.canManageShares} {/if}