Release 0.5.0

This commit is contained in:
hnzio committed 2026-10-22 00:28:49 +02:00
1 parent 4042fe6a64
commit 5a9d7735d4
26 files changed
+1855 -113

No files matched your search

+267
View File
@@ -1101,6 +1101,273 @@ a {
font-size: 0.61rem;
}
.live-state-line {
margin: 0.4rem 0 0;
color: var(--text-muted);
font-size: 0.75rem;
}
.live-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
}
.live-session-summary {
display: grid;
margin-left: auto;
text-align: right;
}
.live-session-summary strong {
font-size: 0.78rem;
}
.live-session-summary span {
color: var(--text-muted);
font-size: 0.65rem;
}
.live-grid.without-rail {
grid-template-columns: minmax(0, 1fr);
}
.live-markdown > :first-child,
.prompter-copy > :first-child {
margin-top: 0;
}
.live-markdown > :last-child,
.prompter-copy > :last-child {
margin-bottom: 0;
}
.live-current-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.4rem;
padding-top: 1rem;
border-top: 1px solid var(--line);
}
.live-current-actions form:last-child {
margin-left: auto;
}
.live-run-item,
.live-reserve-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
margin-top: 0.35rem;
padding: 0.5rem 0.55rem;
border-radius: 0.65rem;
background: var(--panel-soft);
}
.live-run-item.current {
box-shadow: inset 3px 0 var(--accent);
background: var(--accent-soft);
}
.live-run-item > div:first-child,
.live-reserve-item > div:first-child {
display: grid;
min-width: 0;
}
.live-run-item strong,
.live-reserve-item strong {
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.live-run-item small,
.live-reserve-item small {
color: var(--text-muted);
font-size: 0.61rem;
}
.live-run-actions {
display: flex;
align-items: center;
gap: 0.2rem;
}
.live-run-actions form {
display: flex;
}
.live-run-actions button:not(.text-button) {
min-width: 1.65rem;
min-height: 1.65rem;
padding: 0;
}
.live-run-actions .text-button {
padding: 0.3rem;
font-size: 0.62rem;
}
.live-reserve {
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line));
border-radius: 1rem;
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
}
.live-reserve > header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 720;
}
.live-reserve > p {
margin: 0.65rem 0 0;
color: var(--text-muted);
font-size: 0.68rem;
}
.heinzlive-output {
min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem);
background:
radial-gradient(circle at 12% 15%, rgb(103 221 232 / 12%), transparent 32rem), #0b1220;
color: #fff7e8;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.output-connection {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 2rem;
background: rgb(11 18 32 / 78%);
color: rgb(255 247 232 / 68%);
font-size: 0.68rem;
backdrop-filter: blur(10px);
}
.output-connection span {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: #67dde8;
}
.output-connection.problem {
color: #ffd0cb;
}
.output-connection.problem span {
background: #ff776d;
}
.output-content {
display: grid;
max-width: 88rem;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
align-content: center;
margin: 0 auto;
}
.output-kicker,
.output-sources > span {
color: #67dde8;
font-size: clamp(0.68rem, 1.2vw, 0.9rem);
font-weight: 760;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.output-content h1,
.output-idle h1 {
max-width: 16ch;
margin: 0.45rem 0 1.5rem;
font-size: clamp(2.8rem, 8vw, 8rem);
letter-spacing: -0.055em;
line-height: 0.94;
}
.output-copy {
max-width: 44ch;
font-family: ui-serif, Georgia, serif;
font-size: clamp(1.4rem, 2.8vw, 2.7rem);
line-height: 1.35;
}
.output-copy a,
.output-sources a {
color: #e7b957;
}
.output-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 1rem;
margin-top: 2rem;
}
.output-images img {
width: 100%;
max-height: 48vh;
object-fit: contain;
border-radius: 1.2rem;
background: rgb(255 255 255 / 5%);
}
.output-sources {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid rgb(255 255 255 / 14%);
}
.output-sources a {
font-size: 0.85rem;
}
.output-idle {
display: grid;
min-height: calc(100vh - clamp(4rem, 12vw, 12rem));
place-content: center;
text-align: center;
}
.output-idle .brand-mark {
margin: 0 auto 1.5rem;
transform: scale(1.5);
}
.output-idle h1 {
max-width: 18ch;
font-size: clamp(2.4rem, 6vw, 5.5rem);
}
.output-idle p {
margin: 0;
color: rgb(255 247 232 / 62%);
font-size: clamp(0.9rem, 1.8vw, 1.3rem);
}
.quiet-panel {
border: 1px solid var(--line);
border-radius: var(--radius-large);
+1
View File
@@ -7,6 +7,7 @@ declare global {
}
interface PageData {
user?: AuthUser | null;
presentationMode?: boolean;
}
}
}
+6 -1
View File
@@ -13,11 +13,12 @@ const publicPaths = new Set([
]);
export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) {
if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup');
}
@@ -25,6 +26,10 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
if (presentationPath) {
response.headers.set('Cache-Control', 'no-store');
response.headers.set('Referrer-Policy', 'no-referrer');
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
}
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 4,
migrations: 5,
pieces: 6,
shows: 2,
usages: 5,
+137
View File
@@ -0,0 +1,137 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
completeCurrent,
getLiveView,
getPresentationMedia,
getPresentationView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from './live.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-live-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('live mode', () => {
it('restores live state, records actual intervals and applies commands only once', () => {
const started = new Date('2026-10-22T18:00:00.000Z');
const initial = getLiveView('show-october', started);
expect(initial.current).toBeNull();
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: false
});
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
revision: 1,
duplicate: true
});
let live = getLiveView('show-october', new Date('2026-10-22T18:05:00.000Z'));
expect(live.current?.id).toBe('usage-console');
expect(live.current?.actualDurationSeconds).toBe(300);
expect(() =>
moveOpenUsage('show-october', 'usage-effects', live.show.revision, {
sectionId: 'section-stories',
position: 0
})
).toThrow('LIVE_ORDER_LOCKED');
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:06:00.000Z'));
expect(live.current?.id).toBe('usage-effects');
expect(
live.sections
.flatMap((section) => section.usages)
.find((usage) => usage.id === 'usage-console')
).toMatchObject({ status: 'done', actualDurationSeconds: 300 });
expect(
getDatabase()
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
.get('usage-console')
).toEqual({ count: 1 });
expect(() => skipCurrent('show-october', 'stale-command', 1)).toThrow('CONFLICT');
skipCurrent('show-october', 'skip-effects', 2, new Date('2026-10-22T18:06:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:07:00.000Z'));
expect(live.current).toBeNull();
expect(live.completedCount).toBe(2);
goBack('show-october', 'back-effects', 3, new Date('2026-10-22T18:07:00.000Z'));
live = getLiveView('show-october', new Date('2026-10-22T18:08:00.000Z'));
expect(live.current).toMatchObject({ id: 'usage-effects', status: 'current' });
expect(live.current?.actualDurationSeconds).toBe(120);
});
it('changes presentation only explicitly and exposes no private fields', async () => {
const uploads = process.env.SENDEWERK_UPLOAD_DIR!;
mkdirSync(uploads, { recursive: true });
writeFileSync(path.join(uploads, 'presentation.png'), Buffer.from('test-image'));
getDatabase()
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
'presentation-image',
'piece-console',
'presentation.png',
'private-original-name.png',
'image/png',
10,
'Konsole auf dem Tisch',
0,
'2026-10-22T18:00:00.000Z'
);
const token = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
startOrResumeLive('show-october', 'start-output', 0);
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
presentUsage('show-october', 'usage-console', 'present-console', 1);
const output = getPresentationView(token)!;
expect(output.content).toMatchObject({
title: 'Die kleine Konsole mit dem großen Dreh',
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
});
const serialized = JSON.stringify(output);
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
expect(serialized).not.toContain('Herstellerangaben');
expect(serialized).not.toContain('Moderationsstichpunkte');
expect(serialized).not.toContain('private-original-name');
expect(serialized).not.toContain('presentation.png');
const media = await getPresentationMedia(token, 'presentation-image');
expect(media?.data.toString()).toBe('test-image');
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
const replacementToken = issuePresentationAccess('show-october');
expect(getPresentationView(token)).toBeNull();
expect(getPresentationView(replacementToken)?.content?.title).toBe(
'Die kleine Konsole mit dem großen Dreh'
);
});
});
+563
View File
@@ -0,0 +1,563 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { getMediaFile } from './content.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import {
getShow,
movePieceUsage,
type MovePieceUsageInput,
type PieceUsage,
type ShowSection
} from './shows.js';
type LiveStateRow = {
show_id: string;
revision: number;
current_usage_id: string | null;
started_at_utc: string | null;
ended_at_utc: string | null;
presentation_usage_id: string | null;
presentation_revision: number;
updated_at_utc: string;
};
type OrderedUsageRow = {
id: string;
section_id: string;
section_name: string;
section_position: number;
position: number;
status: PieceUsage['status'];
};
export type LiveUsage = PieceUsage & {
sectionId: string;
sectionName: string;
actualDurationSeconds: number;
introHtml: string;
researchHtml: string;
moderationHtml: string;
transitionHtml: string;
};
export type LiveSection = Omit<ShowSection, 'usages'> & { usages: LiveUsage[] };
export type LiveView = {
show: {
id: string;
title: string;
status: string;
revision: number;
totalDurationSeconds: number;
};
liveRevision: number;
startedAtUtc: string | null;
updatedAtUtc: string;
presentationUsageId: string | null;
presentationRevision: number;
sections: LiveSection[];
reserve: LiveUsage[];
current: LiveUsage | null;
next: LiveUsage | null;
completedCount: number;
totalCount: number;
};
export type PresentationView = {
revision: number;
content: null | {
title: string;
html: string;
sources: { label: string; url: string; timecodeSeconds: number | null }[];
images: { id: string; altText: string }[];
};
};
function hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function nowIso(now?: Date) {
return (now ?? new Date()).toISOString();
}
function requireCommandId(commandId: string) {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
}
function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow {
const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId);
if (!show) throw new Error('NOT_FOUND');
database
.prepare(
`INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc)
VALUES (?, ?)`
)
.run(showId, now);
return database
.prepare('SELECT * FROM show_live_states WHERE show_id = ?')
.get(showId) as LiveStateRow;
}
function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] {
return database
.prepare(
`SELECT u.id, s.id AS section_id, s.name AS section_name,
s.position AS section_position, u.position, u.status
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0
ORDER BY s.position, u.position`
)
.all(showId) as OrderedUsageRow[];
}
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
const row = database
.prepare(
`SELECT s.is_reserve_bank FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
}
function closeActiveRun(database: Database.Database, showId: string, now: string) {
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ?
WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, showId);
}
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE piece_usages SET status = 'open'
WHERE status = 'current' AND id != ? AND id IN (
SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ?
)`
)
.run(usageId, showId);
database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId);
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, usageId, now);
database
.prepare(
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
)
.run(usageId, showId);
}
function executeCommand(
showId: string,
commandId: string,
expectedRevision: number,
action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date
) {
requireCommandId(commandId);
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?')
.get(showId, commandId) as { result_revision: number } | undefined;
if (duplicate) return { revision: duplicate.result_revision, duplicate: true };
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string;
};
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now);
const updated = database
.prepare(
`UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ?
WHERE show_id = ? AND revision = ?`
)
.run(now, showId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const revision = expectedRevision + 1;
database
.prepare(
`INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(showId, commandId, action, revision, now);
return { revision, duplicate: false };
})();
}
function actualDurations(database: Database.Database, showId: string, now: string) {
const rows = database
.prepare(
`SELECT usage_id, started_at_utc, ended_at_utc
FROM live_usage_runs WHERE show_id = ?`
)
.all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[];
const durations = new Map<string, number>();
for (const row of rows) {
const seconds = Math.max(
0,
Math.floor(
(new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) /
1000
)
);
durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds);
}
return durations;
}
function liveUsage(
usage: PieceUsage,
sectionId: string,
sectionName: string,
actualSeconds: number
) {
return {
...usage,
sectionId,
sectionName,
actualDurationSeconds: actualSeconds,
introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation),
transitionHtml: renderMarkdown(usage.transition)
};
}
export function getLiveView(showId: string, nowValue?: Date): LiveView {
const database = getDatabase();
const now = nowIso(nowValue);
const state = ensureLiveState(database, showId, now);
const show = getShow(showId);
if (!show) throw new Error('NOT_FOUND');
const durations = actualDurations(database, showId, now);
const sections = show.sections.map((section) => ({
...section,
usages: section.usages.map((usage) =>
liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0)
)
}));
const reserve = show.reserve.map((usage) =>
liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0)
);
const usages = sections.flatMap((section) => section.usages);
const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null;
const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1;
const next =
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
return {
show: {
id: show.id,
title: show.title,
status: show.status,
revision: show.revision,
totalDurationSeconds: show.totalDurationSeconds
},
liveRevision: state.revision,
startedAtUtc: state.started_at_utc,
updatedAtUtc: state.updated_at_utc,
presentationUsageId: state.presentation_usage_id,
presentationRevision: state.presentation_revision,
sections,
reserve,
current,
next,
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
.length,
totalCount: usages.length
};
}
export function startOrResumeLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'start',
(database, state, now) => {
database
.prepare(
`UPDATE shows SET status = 'live', updated_at_utc = ?
WHERE id = ? AND status != 'completed'`
)
.run(now, showId);
database
.prepare(
`UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL
WHERE show_id = ?`
)
.run(now, showId);
const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId);
if (state.current_usage_id && activeRun) return;
const current = state.current_usage_id
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: undefined;
const target =
current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now);
},
nowValue
);
}
function advanceCurrent(
showId: string,
commandId: string,
expectedRevision: number,
status: 'done' | 'skipped',
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
status,
(database, state, now) => {
if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
if (currentIndex < 0) throw new Error('NOT_FOUND');
closeActiveRun(database, showId, now);
database
.prepare('UPDATE piece_usages SET status = ? WHERE id = ?')
.run(status, state.current_usage_id);
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
if (next) activateUsage(database, showId, next.id, now);
else
database
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
.run(showId);
},
nowValue
);
}
export function completeCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue);
}
export function skipCurrent(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue);
}
export function goBack(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'back',
(database, state, now) => {
const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
: ordered.length;
const previous = ordered
.slice(0, Math.max(0, currentIndex))
.reverse()
.find((usage) => usage.status === 'done' || usage.status === 'skipped');
if (!previous) throw new Error('NO_PREVIOUS');
if (state.current_usage_id) {
closeActiveRun(database, showId, now);
database
.prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`)
.run(state.current_usage_id);
}
activateUsage(database, showId, previous.id, now);
},
nowValue
);
}
export function presentUsage(
showId: string,
usageId: string | null,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
usageId ? 'present' : 'clear-presentation',
(database, _state, now) => {
if (usageId) usageBelongsToShow(database, showId, usageId);
database
.prepare(
`UPDATE show_live_states SET presentation_usage_id = ?,
presentation_revision = presentation_revision + 1, updated_at_utc = ?
WHERE show_id = ?`
)
.run(usageId, now, showId);
},
nowValue
);
}
export function moveOpenUsage(
showId: string,
usageId: string,
expectedShowRevision: number,
input: MovePieceUsageInput
) {
const database = getDatabase();
const usage = database
.prepare(
`SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ?`
)
.get(usageId, showId) as { status: PieceUsage['status'] } | undefined;
if (!usage) throw new Error('NOT_FOUND');
if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED');
if (input.sectionId) {
const target = database
.prepare(
`SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position`
)
.all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[];
if (!target.length) {
const section = database
.prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0')
.get(input.sectionId, showId);
if (!section) throw new Error('NOT_FOUND');
}
const withoutMoved = target.filter((item) => item.id !== usageId);
const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open');
const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition;
if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED');
}
return movePieceUsage(showId, usageId, expectedShowRevision, input);
}
export function issuePresentationAccess(showId: string) {
const database = getDatabase();
const now = nowIso();
ensureLiveState(database, showId, now);
const token = randomBytes(32).toString('base64url');
database.transaction(() => {
database
.prepare(
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
)
.run(now, showId);
database
.prepare(
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, hashToken(token), now);
})();
return token;
}
function presentationShowId(database: Database.Database, token: string) {
if (!token || token.length > 200) return null;
const row = database
.prepare(
`SELECT show_id FROM presentation_access_tokens
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(hashToken(token)) as { show_id: string } | undefined;
return row?.show_id ?? null;
}
export function getPresentationView(token: string): PresentationView | null {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const state = ensureLiveState(database, showId, nowIso());
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database
.prepare(
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(state.presentation_usage_id, showId) as
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null }
| undefined;
if (!usage) return { revision: state.presentation_revision, content: null };
const sources = usage.original_piece_id
? (database
.prepare(
`SELECT label, url, timecode_seconds AS timecodeSeconds
FROM piece_sources WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as {
label: string;
url: string;
timecodeSeconds: number | null;
}[])
: [];
const images = usage.original_piece_id
? (database
.prepare(
`SELECT id, alt_text AS altText FROM piece_media
WHERE piece_id = ? ORDER BY position`
)
.all(usage.original_piece_id) as { id: string; altText: string }[])
: [];
return {
revision: state.presentation_revision,
content: {
title: usage.title_snapshot,
html: renderMarkdown(usage.presentation_snapshot),
sources,
images
}
};
}
export async function getPresentationMedia(token: string, mediaId: string) {
const database = getDatabase();
const showId = presentationShowId(database, token);
if (!showId) return null;
const allowed = database
.prepare(
`SELECT 1 FROM show_live_states l
JOIN piece_usages u ON u.id = l.presentation_usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media m ON m.piece_id = u.original_piece_id
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
)
.get(showId, showId, mediaId);
if (!allowed) return null;
return getMediaFile(mediaId);
}
+4 -1
View File
@@ -1,3 +1,6 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user });
export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/')
});
+3 -1
View File
@@ -31,7 +31,9 @@
/>
</svelte:head>
{#if data.user}
{#if data.presentationMode}
{@render children()}
{:else if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const load: PageServerLoad = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return { view };
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let view = $state(untrack(() => data.view));
let connection = $state<'connected' | 'waiting' | 'interrupted'>('connected');
onMount(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const response = await fetch(`/heinzlive/${page.params.token}/state`, {
headers: { Accept: 'application/json' },
cache: 'no-store'
});
if (!response.ok) throw new Error('STATE_UNAVAILABLE');
const next = await response.json();
if (next.revision !== view.revision) view = next;
connection = 'connected';
} catch {
connection = 'interrupted';
}
if (!stopped) timer = setTimeout(refresh, connection === 'interrupted' ? 3000 : 1000);
}
connection = 'waiting';
timer = setTimeout(refresh, 500);
return () => {
stopped = true;
clearTimeout(timer);
};
});
</script>
<svelte:head>
<title>heinzlive</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head>
<main class="heinzlive-output">
<div class="output-connection" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div>
{#if view.content}
<article class="output-content">
<p class="output-kicker">heinzlive</p>
<h1>{view.content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html view.content.html}</div>
{#if view.content.images.length}<div class="output-images">
{#each view.content.images as image (image.id)}
<img src={`/heinzlive/${page.params.token}/media/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if view.content.sources.length}<footer class="output-sources">
<span>Quellen</span>
{#each view.content.sources as source, index (`${index}-${source.url}`)}
<!-- Source URLs are validated as absolute HTTP(S) URLs when the Piece is saved. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else}
<section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
<h1>Bereit für den nächsten Inhalt</h1>
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
</section>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationMedia } from '#lib/server/live.js';
export const GET: RequestHandler = async ({ params }) => {
const media = await getPresentationMedia(params.token, params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -0,0 +1,9 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPresentationView } from '#lib/server/live.js';
export const GET: RequestHandler = ({ params }) => {
const view = getPresentationView(params.token);
if (!view) error(404, 'Präsentationsausgabe nicht gefunden');
return json(view, { headers: { 'Cache-Control': 'no-store' } });
};
+125 -13
View File
@@ -1,15 +1,127 @@
import { getShow } from '#lib/server/previews.js';
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 {
completeCurrent,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
presentUsage,
skipCurrent,
startOrResumeLive
} from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js';
export function load({ url }) {
const show = getShow(url.searchParams.get('show') || undefined);
const usages = show.sections.flatMap((section) =>
section.usages
.filter((usage) => !usage.isReserve)
.map((usage) => ({ ...usage, section: section.name }))
);
const currentIndex = Math.max(
0,
usages.findIndex((usage) => usage.status === 'current')
);
return { show, current: usages[currentIndex] ?? null, next: usages[currentIndex + 1] ?? null };
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.'
};
if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' };
if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' };
if (code === 'LIVE_ORDER_LOCKED')
return {
status: 409,
error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.'
};
if (code === 'SHOW_COMPLETED')
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
}
function attempt(intent: string, operation: () => unknown) {
try {
return { intent, success: true, result: operation() };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent, ...problem });
}
}
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user);
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
return { live: getLiveView(showId), commandId: randomUUID() };
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
throw cause;
}
};
export const actions = {
start: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('start', () =>
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
complete: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('complete', () =>
completeCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
skip: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('skip', () =>
skipCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
back: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('back', () =>
goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
present: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('present', () =>
presentUsage(
text(data, 'showId'),
text(data, 'usageId') || null,
text(data, 'commandId'),
Number(data.get('revision'))
)
);
},
moveUsage: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('moveUsage', () =>
moveOpenUsage(text(data, 'showId'), text(data, 'usageId'), Number(data.get('showRevision')), {
sectionId: text(data, 'sectionId') || null,
position: Number(data.get('position'))
})
);
},
createOutput: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const token = issuePresentationAccess(text(data, 'showId'));
return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/${token}` };
} catch (cause) {
const problem = actionError(cause);
return fail(problem.status, { intent: 'createOutput', ...problem });
}
}
} satisfies Actions;
+359 -66
View File
@@ -1,96 +1,389 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock } from '#lib/format.js';
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { onMount, untrack } from 'svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data }: { data: PageData } = $props();
let { data, form }: PageProps = $props();
let promptSize = $state(1.55);
let showRail = $state(true);
let showTimes = $state(true);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let outputWindow: Window | null = null;
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
$effect(() => {
const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId) {
currentId = nextCurrentId;
loadedAt = Date.now();
}
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
if (typeof settings.scrollY === 'number')
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
} catch {
localStorage.removeItem(key);
}
const interval = setInterval(() => (now = Date.now()), 1000);
const persistScroll = () => persistSettings();
addEventListener('scroll', persistScroll, { passive: true });
return () => {
clearInterval(interval);
removeEventListener('scroll', persistScroll);
};
});
function persistSettings() {
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleRail() {
showRail = !showRail;
persistSettings();
}
function toggleTimes() {
showTimes = !showTimes;
persistSettings();
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
command.value = crypto.randomUUID();
formData.set('commandId', command.value);
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ update }) => {
await update({ reset: false, invalidateAll: true });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ submitter }) => {
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') {
const response = result.data as { outputUrl: string };
if (outputWindow && !outputWindow.closed) {
outputWindow.location.href = response.outputUrl;
popupMessage = 'Präsentationsfenster verbunden.';
} else {
popupMessage = `Präsentationsfenster konnte nicht geöffnet werden: ${response.outputUrl}`;
}
} else {
outputWindow?.close();
}
await update({ reset: false, invalidateAll: false });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
</script>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head>
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class="page-wrap live-page">
<div class="page-wrap live-page live-controller">
<header class="live-header">
<div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p>
<h1>{data.show.title}</h1>
<p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
<h1>{data.live.show.title}</h1>
<p class="live-state-line">
{data.live.startedAtUtc
? 'Live-Stand wird automatisch gespeichert.'
: 'Noch nicht gestartet.'}
</p>
</div>
<div class="live-header-actions">
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => (promptSize = Math.max(1.15, promptSize - 0.1))}
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => (promptSize = Math.min(2.1, promptSize + 0.1))}
aria-label="Schrift vergrößern">A+</button
<button type="button" onclick={() => changePromptSize(0.1)} aria-label="Schrift vergrößern"
>A+</button
>
</div>
<a href={resolve('/sendungen/[id]', { id: data.show.id })}>Planung schließen</a>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}
>{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}</button
>
<button type="button" class="text-button" aria-pressed={!showRail} onclick={toggleRail}
>{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}</button
>
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}>Zur Planung</a>
</div>
</header>
<PreviewNotice area="Live-Modus" />
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
<div class="live-grid">
{#if data.current}<section class="live-current" style={`--prompt-size: ${promptSize}rem`}>
<section class="live-toolbar" aria-label="Live-Sitzung">
<form method="POST" action="?/start" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button>
</form>
<form method="POST" action="?/createOutput" use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Präsentationsfenster öffnen</button>
</form>
<div class="live-session-summary">
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
<span>{progress}% bearbeitet</span>
</div>
</section>
<div class:without-rail={!showRail} class="live-grid">
{#if data.live.current}<main
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
>
<div class="live-kicker">
<span>Aktuell · {data.current.section}</span><strong
>{formatClock(data.current.durationSeconds)}</strong
>
</div>
<h2>{data.current.title}</h2>
<div class="prompter-copy">{data.current.intro}</div>
<div class="moderation-card">
<span>Moderationsstichpunkte</span>
<p>{data.current.moderation}</p>
<span>Aktuell · {data.live.current.sectionName}</span>
{#if showTimes}<strong
>{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}</strong
>{/if}
</div>
<h2 id="current-title">{data.live.current.title}</h2>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
{#if data.live.current.moderationHtml}<section class="moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
</section>{/if}
<details class="research-details">
<summary>Recherche öffnen</summary>
<p>
Die Recherche bleibt im echten Live-Modus standardmäßig geschlossen und bewahrt beim
Öffnen die Leseposition.
</p>
<summary>Manuelle Recherche öffnen</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
</details>
<div class="transition-card">
<span>Überleitung</span>
<p>{data.current.transition}</p>
</div>
</section>{:else}<section class="live-current live-empty">
<p class="eyebrow">Noch kein Piece eingeplant</p>
<h2>Diese Sendung hat noch keinen moderierbaren Inhalt.</h2>
<p>In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.</p>
</section>{/if}
{#if data.live.current.transitionHtml}<section class="transition-card">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
</section>{/if}
<aside class="live-rail">
<div class="next-card">
<span>Als Nächstes</span>
{#if data.next}
<h3>{data.next.title}</h3>
<p>{data.next.section} · {formatClock(data.next.durationSeconds)}</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres Stück eingeplant.</p>
{/if}
</div>
<div class="live-progress">
<header><span>Ablauf</span><strong>2 / 4</strong></header>
<div class="progress-track"><span></span></div>
{#each data.show.sections as section, index (section.id)}
<div class:active={index === 1} class="rail-section">
<span>{String(index + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong><small>{formatClock(section.durationSeconds)}</small>
<div class="live-current-actions">
<form method="POST" action="?/back" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zurück</button>
</form>
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
</form>
<form method="POST" action="?/skip" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Überspringen</button>
</form>
<form method="POST" action="?/complete" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">Erledigen und weiter</button>
</form>
</div>
</main>{:else}<main class="live-current live-empty">
<p class="eyebrow">Kein aktives Piece</p>
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
<p>
{data.live.totalCount
? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p>
{#if data.live.completedCount}<form
method="POST"
action="?/back"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Zum letzten Piece zurück</button>
</form>{/if}
</main>{/if}
{#if showRail}<aside class="live-rail">
<section class="next-card">
<span>Als Nächstes</span>
{#if data.live.next}
<h3>{data.live.next.title}</h3>
<p>
{data.live.next.sectionName}{#if showTimes}
· {formatClock(data.live.next.durationSeconds)}{/if}
</p>
{:else}
<h3>Sendungsende</h3>
<p>Kein weiteres offenes Piece eingeplant.</p>
{/if}
</section>
<section class="live-progress">
<header>
<span>Ablauf</span><strong>{data.live.completedCount} / {data.live.totalCount}</strong>
</header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
{#each data.live.sections as section, sectionIndex (section.id)}
<div class:active={section.id === data.live.current?.sectionId} class="rail-section">
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="readonly-actions" aria-label="Spätere Live-Aktionen">
<span>Zurück</span><strong>In Etappe 5 steuerbar</strong><span>Erledigen</span>
</div>
</aside>
{#each section.usages as usage, usageIndex (usage.id)}
<div class:current={usage.id === data.live.current?.id} class="live-run-item">
<div>
<strong>{usage.title}</strong><small>{usage.status}</small>
</div>
<div class="live-run-actions">
{#if usage.status === 'open'}
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex - 1} />
<button
type="submit"
disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex + 1} />
<button
type="submit"
disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}>↓</button
>
</form>
{/if}
<form method="POST" action="?/present" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button type="submit" aria-label={`${usage.title} präsentieren`}>↗</button>
</form>
</div>
</div>
{/each}
{/each}
</section>
<section class="live-reserve">
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
{#each data.live.reserve as usage, reserveIndex (usage.id)}
<div class="live-reserve-item">
<div>
<strong>{usage.title}</strong>{#if showTimes}<small
>{formatClock(usage.durationSeconds)}</small
>{/if}
</div>
<div class="live-run-actions">
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex - 1} />
<button
type="submit"
disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
>
</form>
<form method="POST" action="?/moveUsage" use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex + 1} />
<button
type="submit"
disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}>↓</button
>
</form>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action="?/moveUsage"
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input
type="hidden"
name="sectionId"
value={data.live.current?.sectionId ?? data.live.sections[0]?.id}
/>
<input
type="hidden"
name="position"
value={(data.live.current?.position ?? -1) + 1}
/>
<button class="text-button" type="submit">Als Nächstes</button>
</form>{/if}
</div>
</div>
{/each}
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
</section>
</aside>{/if}
</div>
</div>
+1 -1
View File
@@ -175,7 +175,7 @@
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Vorschau <span>↗</span></a
>Live-Steuerung <span>↗</span></a
>
</div>
</header>