Initial release 0.1.0

This commit is contained in:
hnzio committed 2026-10-21 14:50:31 +02:00
commit 9f97d8407a
52 files changed
+6774

No files matched your search

+1350
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
declare global {
namespace App {}
}
export {};
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de" data-theme="light" data-theme-preference="system">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f4f1eb" />
<script>
(function () {
try {
var stored = localStorage.getItem('sendewerk-theme');
var preference = ['light', 'dark', 'system'].includes(stored) ? stored : 'system';
var theme =
preference === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: preference;
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.themePreference = preference;
} catch (error) {
console.warn('Theme preference could not be restored.', error);
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
let { name }: { name: string } = $props();
</script>
<svg viewBox="0 0 24 24" aria-hidden="true">
{#if name === 'inbox'}
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
{:else if name === 'library'}
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
{:else if name === 'shows'}
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
{:else if name === 'archive'}
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
{:else if name === 'settings'}
<circle cx="12" cy="12" r="3" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{/if}
</svg>
<style>
svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
let { area }: { area: string } = $props();
</script>
<aside class="preview-notice" aria-label="Hinweis zur Gestaltungsvorschau">
<span class="preview-dot" aria-hidden="true"></span>
<div>
<strong>Gestaltungsvorschau: {area}</strong>
<p>
In Etappe 1 schreibgeschützt. Die gezeigten Inhalte stammen aus der lokalen SQLite-Datenbank.
</p>
</div>
</aside>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { onMount } from 'svelte';
type Preference = 'system' | 'light' | 'dark';
const preferences: Preference[] = ['system', 'light', 'dark'];
const labels: Record<Preference, string> = {
system: 'System',
light: 'Hell',
dark: 'Dunkel'
};
let preference = $state<Preference>('system');
function apply(next: Preference) {
preference = next;
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
document.documentElement.dataset.themePreference = next;
localStorage.setItem('sendewerk-theme', next);
}
function cycle() {
const index = preferences.indexOf(preference);
apply(preferences[(index + 1) % preferences.length]);
}
onMount(() => {
const stored = localStorage.getItem('sendewerk-theme');
preference = preferences.includes(stored as Preference) ? (stored as Preference) : 'system';
apply(preference);
const media = window.matchMedia('(prefers-color-scheme: dark)');
const followSystem = () => {
if (preference === 'system') apply('system');
};
media.addEventListener('change', followSystem);
return () => media.removeEventListener('change', followSystem);
});
</script>
<button
class="theme-toggle"
type="button"
onclick={cycle}
aria-label={`Darstellung: ${labels[preference]}`}
>
<span class="theme-symbol" aria-hidden="true">
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'}
</span>
<span>{labels[preference]}</span>
</button>
+12
View File
@@ -0,0 +1,12 @@
export function formatDuration(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours} Std. ${minutes.toString().padStart(2, '0')} Min.`;
return `${minutes} Min.`;
}
export function formatClock(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')} h` : `${minutes} min`;
}
+1
View File
@@ -0,0 +1 @@
export {};
+12
View File
@@ -0,0 +1,12 @@
import path from 'node:path';
function configuredPath(value: string | undefined, fallback: string): string {
return path.resolve(process.cwd(), value || fallback);
}
export function getServerPaths() {
return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
};
}
+77
View File
@@ -0,0 +1,77 @@
import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const temporaryDirectories: string[] = [];
afterEach(async () => {
const { closeDatabase } = await import('./database.js');
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
vi.resetModules();
});
describe('database setup', () => {
it('runs migrations and the seed repeatedly in an isolated database', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-test-'));
temporaryDirectories.push(directory);
const databasePath = path.join(directory, 'test.sqlite');
const environment = {
...process.env,
SENDEWERK_DB_PATH: databasePath,
SENDEWERK_UPLOAD_DIR: path.join(directory, 'uploads')
};
for (let run = 0; run < 2; run += 1) {
execFileSync(process.execPath, ['scripts/migrate.mjs'], {
cwd: process.cwd(),
env: environment
});
execFileSync(process.execPath, ['scripts/seed.mjs'], {
cwd: process.cwd(),
env: environment
});
}
const database = new Database(databasePath, { readonly: true });
const counts = database
.prepare(
`SELECT
(SELECT count(*) FROM schema_migrations) AS migrations,
(SELECT count(*) FROM pieces) AS pieces,
(SELECT count(*) FROM shows) AS shows,
(SELECT count(*) FROM piece_usages) AS usages`
)
.get() as { migrations: number; pieces: number; shows: number; usages: number };
database.close();
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 });
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
});
it('does not count reserve pieces or explicit sections twice', async () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-duration-test-'));
temporaryDirectories.push(directory);
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 });
const { getShow } = await import('./previews.js');
const show = getShow();
expect(show.sections.map((section) => section.durationSeconds)).toEqual([300, 1200, 3600]);
expect(show.totalDurationSeconds).toBe(5100);
expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540);
});
});
+21
View File
@@ -0,0 +1,21 @@
import Database from 'better-sqlite3';
import { getServerPaths } from './config.js';
let database: Database.Database | undefined;
export function getDatabase(): Database.Database {
if (!database) {
database = new Database(getServerPaths().database, { fileMustExist: true });
database.pragma('foreign_keys = ON');
}
return database;
}
export function closeDatabase(): void {
database?.close();
database = undefined;
}
process.on('sveltekit:shutdown', () => {
closeDatabase();
});
+228
View File
@@ -0,0 +1,228 @@
import { getDatabase } from './database.js';
export type PreviewPiece = {
id: string;
title: string;
summary: string;
status: 'inbox' | 'preparing' | 'ready' | 'archived';
durationSeconds: number;
format: string;
category: string;
categoryColor: string;
topics: string[];
sourceCount: number;
};
export type PreviewUsage = {
id: string;
title: string;
durationSeconds: number;
status: 'open' | 'current' | 'done' | 'skipped';
intro: string;
moderation: string;
transition: string;
isReserve: boolean;
};
export type PreviewSection = {
id: string;
name: string;
category: string;
categoryColor: string;
durationSeconds: number;
usages: PreviewUsage[];
};
export type PreviewShow = {
id: string;
title: string;
startsAtUtc: string;
startsAtBerlin: string;
twitchTitle: string;
announcement: string;
status: string;
sections: PreviewSection[];
totalDurationSeconds: number;
};
type PieceRow = {
id: string;
title: string;
summary: string;
status: PreviewPiece['status'];
duration_seconds: number;
format: string;
category: string | null;
category_color: string | null;
topics: string | null;
source_count: number;
};
export function getPieces(): PreviewPiece[] {
const rows = getDatabase()
.prepare(
`SELECT
p.id,
p.title,
p.summary,
p.status,
p.duration_seconds,
COALESCE(f.name, 'Ohne Format') AS format,
(
SELECT c.name FROM piece_category_suggestions pcs
JOIN section_categories c ON c.id = pcs.category_id
WHERE pcs.piece_id = p.id ORDER BY c.sort_order LIMIT 1
) AS category,
(
SELECT c.color FROM piece_category_suggestions pcs
JOIN section_categories c ON c.id = pcs.category_id
WHERE pcs.piece_id = p.id ORDER BY c.sort_order LIMIT 1
) AS category_color,
(
SELECT group_concat(t.name, '||') FROM piece_topics pt
JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = p.id
) AS topics,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived'
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
)
.all() as PieceRow[];
return rows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary,
status: row.status,
durationSeconds: row.duration_seconds,
format: row.format,
category: row.category ?? 'Offen',
categoryColor: row.category_color ?? '#8b8f98',
topics: row.topics?.split('||') ?? [],
sourceCount: row.source_count
}));
}
type ShowRow = {
id: string;
title: string;
starts_at_utc: string;
twitch_title: string;
announcement_text: string;
status: string;
};
type SectionRow = {
id: string;
name: string;
category: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
};
type UsageRow = {
id: string;
show_section_id: string;
title_snapshot: string;
duration_seconds: number;
status: PreviewUsage['status'];
intro_snapshot: string;
moderation_snapshot: string;
transition_snapshot: string;
is_reserve: number;
};
export function getShow(): PreviewShow {
const database = getDatabase();
const show = database
.prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status
FROM shows ORDER BY starts_at_utc DESC LIMIT 1`
)
.get() as ShowRow | undefined;
if (!show) throw new Error('No preview show found. Run npm run db:seed.');
const sectionRows = database
.prepare(
`SELECT s.id, s.name, c.name AS category, c.color AS category_color,
s.explicit_duration_seconds
FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position`
)
.all(show.id) as SectionRow[];
const usageRows = database
.prepare(
`SELECT id, show_section_id, title_snapshot, duration_seconds, status,
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve
FROM piece_usages WHERE show_section_id IN (
SELECT id FROM show_sections WHERE show_id = ?
) ORDER BY position`
)
.all(show.id) as UsageRow[];
const sections = sectionRows.map((section) => {
const usages = usageRows
.filter((usage) => usage.show_section_id === section.id)
.map((usage) => ({
id: usage.id,
title: usage.title_snapshot,
durationSeconds: usage.duration_seconds,
status: usage.status,
intro: usage.intro_snapshot,
moderation: usage.moderation_snapshot,
transition: usage.transition_snapshot,
isReserve: usage.is_reserve === 1
}));
const plannedUsages = usages.filter((usage) => !usage.isReserve);
return {
id: section.id,
name: section.name,
category: section.category ?? 'Offen',
categoryColor: section.category_color ?? '#8b8f98',
durationSeconds:
section.explicit_duration_seconds ??
plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0),
usages
};
});
return {
id: show.id,
title: show.title,
startsAtUtc: show.starts_at_utc,
startsAtBerlin: new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(show.starts_at_utc)),
twitchTitle: show.twitch_title,
announcement: show.announcement_text,
status: show.status,
sections,
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
};
}
export function getTaxonomy() {
const database = getDatabase();
return {
categories: database
.prepare('SELECT id, name, color FROM section_categories ORDER BY sort_order')
.all() as { id: string; name: string; color: string }[],
formats: database.prepare('SELECT id, name FROM formats ORDER BY name').all() as {
id: string;
name: string;
}[],
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as {
id: string;
name: string;
}[]
};
}
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import '../app.css';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import NavIcon from '#lib/components/NavIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props();
const navigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' },
{ href: '/verwaltung', label: 'Verwaltung', icon: 'settings' }
] as const;
function isActive(href: string) {
return (
page.url.pathname.startsWith(href) || (href === '/sendungen' && page.url.pathname === '/live')
);
}
</script>
<svelte:head>
<meta
name="description"
content="Sendewerk organisiert redaktionelle Inhalte und heinzlive-Sendungen."
/>
</svelte:head>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
<ThemeToggle />
<div class="stage-indicator">
<span></span>
<div><strong>Etappe 1</strong><small>Lokaler Entwurf</small></div>
</div>
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(307, '/fundus');
}
+20
View File
@@ -0,0 +1,20 @@
<svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Bewahrte Sendungen</p>
<h1>Archiv</h1>
<p class="page-intro">
Abgeschlossene Abläufe bleiben hier in ihrem damaligen Stand nachvollziehbar.
</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<p class="eyebrow">Geplant für Etappe 6</p>
<h2>Historie braucht klare Regeln.</h2>
<p>
Stabile Snapshots, Verwendungshistorie und bewusste Archivierung werden gemeinsam
implementiert, statt jetzt nur eine leere Liste vorzutäuschen.
</p>
</section>
</div>
+21
View File
@@ -0,0 +1,21 @@
<svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Sammelstelle</p>
<h1>Eingang</h1>
<p class="page-intro">
Neue Gedanken und spätere RSS-Fundstücke landen hier, bevor sie in den Fundus übernommen
werden.
</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<p class="eyebrow">Geplant für Etappe 3 und 7</p>
<h2>Noch ist der Tresen leer.</h2>
<p>
Schnellaufnahme, Feed-Vorschau und bewusste Übernahme werden erst mit den dazugehörigen
Speicher- und Sicherheitsregeln umgesetzt.
</p>
</section>
</div>
+5
View File
@@ -0,0 +1,5 @@
import { getPieces } from '#lib/server/previews.js';
export function load() {
return { pieces: getPieces() };
}
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock } from '#lib/format.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let query = $state('');
let filter = $state<'all' | 'ready' | 'preparing'>('all');
let filteredPieces = $derived(
data.pieces.filter((piece) => {
const text =
`${piece.title} ${piece.summary} ${piece.format} ${piece.topics.join(' ')}`.toLowerCase();
return text.includes(query.toLowerCase()) && (filter === 'all' || piece.status === filter);
})
);
const statusLabel = {
ready: 'Sendefertig',
preparing: 'In Vorbereitung',
inbox: 'Eingang',
archived: 'Archiviert'
};
</script>
<svelte:head><title>Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap">
<header class="page-heading split-heading">
<div>
<p class="eyebrow">Redaktioneller Fundus</p>
<h1>Gute Stücke, sofort griffbereit.</h1>
<p class="page-intro">
Vorbereitete Ideen, Gespräche und Fundstücke für die nächste Sendung.
</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
</div>
</header>
<PreviewNotice area="Fundus" />
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern">
<label class="search-field">
<span aria-hidden="true">⌕</span>
<span class="sr-only">Fundus durchsuchen</span>
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" />
</label>
<div class="filter-tabs" aria-label="Status filtern">
<button class:active={filter === 'all'} type="button" onclick={() => (filter = 'all')}
>Alle</button
>
<button class:active={filter === 'ready'} type="button" onclick={() => (filter = 'ready')}
>Sendefertig</button
>
<button
class:active={filter === 'preparing'}
type="button"
onclick={() => (filter = 'preparing')}>In Arbeit</button
>
</div>
</section>
{#if filteredPieces.length > 0}
<div class="piece-grid">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
<div class="piece-card-topline">
<span class="category-label"><i></i>{piece.category}</span>
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
>
</div>
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
</div>
<footer>
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</footer>
</article>
{/each}
</div>
{:else}
<div class="empty-state">
<span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p>
<button
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
</div>
{/if}
</div>
+12
View File
@@ -0,0 +1,12 @@
import { getDatabase } from '#lib/server/database.js';
import { json } from '@sveltejs/kit';
export function GET() {
try {
getDatabase().prepare('SELECT 1').get();
return json({ status: 'ok' });
} catch (error) {
console.error('Healthcheck failed', error);
return json({ status: 'error' }, { status: 503 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { getShow } from '#lib/server/previews.js';
export function load() {
const show = getShow();
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], next: usages[currentIndex + 1] ?? null };
}
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock } from '#lib/format.js';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let promptSize = $state(1.55);
</script>
<svelte:head><title>Live-Vorschau | Sendewerk</title></svelte:head>
<div class="page-wrap live-page">
<header class="live-header">
<div>
<p class="eyebrow"><span class="live-pulse"></span> Live-Modus · Vorschau</p>
<h1>{data.show.title}</h1>
</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))}
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
>
</div>
<a href={resolve('/sendungen')}>Planung schließen</a>
</div>
</header>
<PreviewNotice area="Live-Modus" />
<div class="live-grid">
<section class="live-current" style={`--prompt-size: ${promptSize}rem`}>
<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>
</div>
<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>
</details>
<div class="transition-card">
<span>Überleitung</span>
<p>{data.current.transition}</p>
</div>
</section>
<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>
</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>
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
import { getPieces, getShow } from '#lib/server/previews.js';
export function load() {
return { pieces: getPieces(), show: getShow() };
}
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import { resolve } from '$app/paths';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let mobilePane = $state<'fundus' | 'plan'>('plan');
let plannedCount = $derived(
data.show.sections.reduce(
(total, section) => total + section.usages.filter((usage) => !usage.isReserve).length,
0
)
);
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div>
<p class="eyebrow">Nächste Sendung · {data.show.startsAtBerlin} Uhr</p>
<h1>{data.show.title}</h1>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
<div>
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div>
<a class="live-link" href={resolve('/live')}>Live-Vorschau <span>↗</span></a>
</div>
</header>
<PreviewNotice area="Sendungsbau" />
<div class="mobile-pane-switch" aria-label="Arbeitsbereich auswählen">
<button
class:active={mobilePane === 'fundus'}
type="button"
onclick={() => (mobilePane = 'fundus')}>Fundus</button
>
<button class:active={mobilePane === 'plan'} type="button" onclick={() => (mobilePane = 'plan')}
>Ablauf</button
>
</div>
<div class="builder-workspace">
<section
class="builder-pane source-pane"
class:mobile-hidden={mobilePane !== 'fundus'}
aria-labelledby="source-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Quelle</p>
<h2 id="source-title">Sendefertiger Fundus</h2>
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<label class="compact-search">
<span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span>
<input
type="search"
placeholder="Fundus durchsuchen"
disabled
title="Die vollständige Suche folgt in Etappe 3"
/>
</label>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
<div>
<span class="category-label"><i></i>{piece.category}</span><span
>{formatClock(piece.durationSeconds)}</span
>
</div>
<h3>{piece.title}</h3>
<p>{piece.format} · {piece.topics.join(', ')}</p>
</article>
{/each}
</div>
<div class="reserve-box">
<div>
<span aria-hidden="true">◇</span>
<div><strong>Reservebank</strong><small>1 Stück vorbereitet</small></div>
</div>
<p>„Pixelart, die nicht nostalgisch sein will“ wartet als Ersatz.</p>
</div>
</section>
<section
class="builder-pane run-pane"
class:mobile-hidden={mobilePane !== 'plan'}
aria-labelledby="run-title"
>
<header class="pane-heading">
<div>
<p class="eyebrow">Sendungsplan</p>
<h2 id="run-title">Ablauf</h2>
</div>
<span>{formatDuration(data.show.totalDurationSeconds)}</span>
</header>
<div class="run-list">
{#each data.show.sections as section, index (section.id)}
<article class="run-section" style={`--category: ${section.categoryColor}`}>
<header>
<div class="section-number">{String(index + 1).padStart(2, '0')}</div>
<div>
<span class="category-label"><i></i>{section.category}</span>
<h3>{section.name}</h3>
</div>
<strong>{formatClock(section.durationSeconds)}</strong>
</header>
{#if section.usages.filter((usage) => !usage.isReserve).length > 0}
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
<span class="drag-hint" aria-hidden="true">⠿</span>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Eigene Sendungskopie</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
>{usage.status === 'current' ? 'Aktuell' : 'Offen'}</span
>
</div>
{/each}
</div>
{:else}
<div class="section-placeholder">
Eigenständiger Spieleblock · keine Piece-Kopie nötig
</div>
{/if}
</article>
{/each}
</div>
</section>
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
import { getTaxonomy } from '#lib/server/previews.js';
export function load() {
return { taxonomy: getTaxonomy() };
}
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">System & Struktur</p>
<h1>Verwaltung</h1>
<p class="page-intro">
Die bereits angelegte Taxonomie ist sichtbar; Bearbeitung und Benutzerverwaltung folgen mit
serverseitigen Rechten.
</p>
</header>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2>
<div class="taxonomy-list">
{#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}</span
>{/each}
</div>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2>
<div class="taxonomy-list">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each}
</div>
</section>
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
</div>
</section>
<section class="quiet-panel planned-settings">
<p class="eyebrow">Etappe 2</p>
<h2>Konten & Rollen</h2>
<p>
Barkeeper, Thekencrew und technische Zugänge werden erst mit vollständiger Authentifizierung
verwaltbar.
</p>
</section>
</div>
</div>