Initial release 0.1.0
This commit is contained in:
commit
9f97d8407a
52 files changed
+6774
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}[]
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user