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

+8
View File
@@ -0,0 +1,8 @@
.git
.data
.env
.svelte-kit
build
node_modules
playwright-report
test-results
+3
View File
@@ -0,0 +1,3 @@
# Relative paths are resolved from the project directory.
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads
+19
View File
@@ -0,0 +1,19 @@
node_modules
/.svelte-kit
/build
/.data
/uploads
/test-results
/playwright-report
/coverage
/.env
/.env.*
!/.env.example
*.sqlite
*.sqlite-*
*.log
.idea
.vscode
.DS_Store
AGENTS.md
Sendewerk-OpenCode-Bauplan.md
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+7
View File
@@ -0,0 +1,7 @@
node_modules
build
.svelte-kit
package-lock.json
test-results
playwright-report
Sendewerk-OpenCode-Bauplan.md
+17
View File
@@ -0,0 +1,17 @@
{
"id": "io.hnz.sendewerk",
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
"localstorage": {
"sqlite": {
"paths": ["/app/data/sendewerk.sqlite"]
}
}
},
"manifestVersion": 2
}
+16
View File
@@ -0,0 +1,16 @@
FROM cloudron/node-base:24-20260920@sha256:d984683ec59bf2379130bf41cf3c2b6bc0453f327297d7183525cb05424e7b34
WORKDIR /app/code
COPY package.json package-lock.json ./
RUN npm ci
COPY . ./
RUN npm run build \
&& npm prune --omit=dev \
&& chmod +x /app/code/start.sh
ENV NODE_ENV=production
EXPOSE 3000
CMD ["/app/code/start.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sendewerk contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+105
View File
@@ -0,0 +1,105 @@
# Sendewerk
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte
Live-Steuerung folgen in späteren Etappen.
## Voraussetzungen
- Linux
- Node.js 22.17 oder neuer (Node 24 LTS ist für das Cloudron-Image vorgesehen)
- npm 10 oder neuer
Cloudron, Docker, eine Domain und externe Dienste sind für den lokalen Betrieb
nicht erforderlich.
## Frischer lokaler Start
```sh
npm ci
cp .env.example .env
npm run setup
npm run dev
```
Danach ist die Anwendung unter <http://localhost:5173> erreichbar. `setup`
erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten.
## Konfiguration
| Variable | Vorgabe | Bedeutung |
| ---------------------- | ------------------------ | ----------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`.
## Befehle
| Befehl | Zweck |
| -------------------- | ---------------------------------------------------------- |
| `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
| `npm run db:seed` | Reproduzierbare Beispieldaten laden |
| `npm run dev` | Entwicklungsserver starten |
| `npm run check` | Svelte- und TypeScript-Prüfung |
| `npm run lint` | Formatierung und ESLint prüfen |
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
| `npm run test:e2e` | Browserprüfungen gegen eine getrennte E2E-Datenbank |
| `npm run build` | Produktionsartefakt in `build/` erstellen |
| `npm start` | gebautes Artefakt mit `node build` starten |
## Lokaler Produktionslauf
```sh
npm ci
npm run setup
npm run build
HOST=127.0.0.1 PORT=3000 npm start
```
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
Datenbankzugriff.
Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
```sh
npx playwright install chromium
npm run test:e2e
```
## Container und Cloudron
Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
`start.sh`. Ein lokaler Container-Smoke-Test kann nach dem Build so erfolgen:
```sh
docker build -t sendewerk:local .
docker run --rm -p 3000:3000 -v sendewerk-data:/app/data sendewerk:local
```
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
## Dokumentation
- `docs/ARCHITECTURE.md`: Systemgrenzen und geplanter Gesamtdatenbestand
- `docs/DECISIONS.md`: technische Entscheidungen und offizielle Quellen
- `docs/ACCEPTANCE.md`: Abnahmekriterien und konkrete Nachweise
- `docs/STATUS.md`: aktueller Projektstand und bekannte Grenzen
Der Anwendungscode steht unter der MIT-Lizenz. Rechte an später eingestellten
Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
+38
View File
@@ -0,0 +1,38 @@
# Abnahme
## Etappe 1
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 4 Playwright-Abläufe; 1440x1000, 1280x900 und 390x844; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
## Ausgeführte Prüfungen
- `npm ci`: erfolgreich, 190 Pakete geprüft, 0 gemeldete Schwachstellen
- `npm run setup`, anschließend erneute Migration und erneuter Seed: erfolgreich
- `npm test`: 2 Verhaltenstests erfolgreich
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 4 von 4 Browsertests erfolgreich
- Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich
- `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich
## Nicht erfolgreich oder nicht ausführbar
- `docker build -t sendewerk:local .`: nicht ausgeführt, weil der installierte
Docker-Client keinen laufenden Daemon unter `/var/run/docker.sock` erreichen
konnte.
- Echte Cloudron-Installation: mangels Serverzugang nicht ausgeführt.
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
Python-Modul `playwright` nicht installiert war. Die projektgebundene
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich.
+84
View File
@@ -0,0 +1,84 @@
# Architektur
## Systemgrenze
Sendewerk ist ein einzelner SvelteKit-Prozess. Browserseiten, serverseitige
Actions, spätere REST-Endpunkte und Hintergrundaufgaben verwenden dieselbe
Geschäftslogik. SQLite und ein lokales Upload-Verzeichnis sind die einzigen
persistenten Laufzeitabhängigkeiten. Externe Mail-, RSS- oder KI-Dienste sind
nicht Voraussetzung für Kernfunktionen.
```text
Browser -> SvelteKit-Routen -> Anwendungsdienste -> SQLite
| -> Upload-Verzeichnis
+-> später: Mail/RSS über Adapter
```
Servercode lebt unter `src/lib/server` und darf nicht in Browser-Bundles
gelangen. Routen liefern nur die für ihre Ansicht erlaubten Daten. Fachlogik
wird nicht an Svelte-Komponenten gebunden, damit Browseraktionen und die
spätere API dieselben Regeln verwenden.
## Laufzeit und Speicher
- Lokal: `.data/sendewerk.sqlite` und `.data/uploads`, jeweils konfigurierbar.
- Tests: pro Lauf ein temporäres Verzeichnis unter dem Betriebssystem-Tempdir.
- Cloudron: `/app/data/sendewerk.sqlite` und `/app/data/uploads`.
- Migrationen liegen nummeriert unter `migrations/`, laufen in Transaktionen
und werden in `schema_migrations` protokolliert.
- Der Seed nutzt stabile IDs und Transaktionen. Wiederholung liefert denselben
fachlichen Ausgangsbestand, ohne Migrationstabellen anzutasten.
- Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative
ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`.
## Geplantes Gesamtdatenmodell
Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten
Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel
Beim Einplanen entsteht eine bearbeitbare `piece_usage`-Kopie der
sendungsrelevanten Texte und Dauer. Das Original bleibt verknüpft, wird aber
nicht live gespiegelt. Beim Abschluss wird der damalige Sendungsinhalt stabil
eingefroren. Eine historische Sendung wird dupliziert, nie weiterbearbeitet.
### Dauerregel
Eine Abschnittsdauer ist entweder die Summe ihrer eingeplanten Verwendungen
oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Auf schmalen
Ansichten werden sie über eine gut erreichbare Umschaltung einzeln gezeigt.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare
`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende
Fachabläufe werden jedoch nicht vorgetäuscht.
## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden serverseitig an jeder Route und
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
+83
View File
@@ -0,0 +1,83 @@
# Entscheidungen
Stand der Quellenprüfung: 21. Oktober 2026. Verwendet wurden offizielle
Dokumentationen und die npm-Registry für konkrete Paket-Metadaten.
## D-001: SvelteKit 3 und Node-Unterstützung
Die Anwendung verwendet SvelteKit 3, Svelte 5, TypeScript und Vite 8. Das
Projekt wurde gemäß aktueller Empfehlung mit `npx sv create` angelegt. Die
Kit-Konfiguration liegt bei SvelteKit 3 in `vite.config.ts`, nicht in einer
`svelte.config.js`.
Die lokale Mindestversion ist Node 22.17. Das Cloudron-Image nutzt Node 24 LTS.
Der vorhandene lokale Lauf wurde mit Node 22.23.1 geplant.
Quellen:
- <https://svelte.dev/docs/kit/creating-a-project>
- <https://svelte.dev/docs/kit/migrating-to-sveltekit-3>
- <https://svelte.dev/docs/cli/sv-create>
- npm-Metadaten für `@sveltejs/kit` 3.1.0 am 21.10.2026
## D-002: Offizieller Node-Adapter
`@sveltejs/adapter-node` erzeugt den Produktionsserver in `build/`. Gebaut wird
mit `npm run build`, gestartet mit `node build`. `HOST` und `PORT` konfigurieren
den Server. Der Adapter behandelt SIGTERM/SIGINT und kontrolliertes Herunter-
fahren; ein eigener Server ist für Etappe 1 nicht erforderlich.
Quelle: <https://svelte.dev/docs/kit/adapter-node>
## D-003: SQLite mit better-sqlite3
`better-sqlite3` 13.x wird als gekapselter synchroner Treiber verwendet. Das
Paket ist MIT-lizenziert und verlangt Node 22 oder neuer. Gegenüber
`node:sqlite` wird damit in Etappe 1 keine API verwendet, die in der offiziellen
Node-24-Dokumentation noch als Release Candidate gekennzeichnet ist. Die
Datenbankgrenze bleibt in `src/lib/server` gekapselt.
Migrationen sind eigene nummerierte SQL-Dateien und laufen transaktional. WAL
wird in Etappe 1 nicht aktiviert; für die kleine Redaktion ist das
Rollback-Journal zunächst ausreichend und vereinfacht die frühe
Betriebsprüfung.
Quellen:
- <https://nodejs.org/docs/latest-v24.x/api/sqlite.html>
- <https://www.sqlite.org/lang_transaction.html>
- npm-Metadaten für `better-sqlite3` 13.0.3 am 21.10.2026
## D-004: Cloudron-Paket
Das Paket nutzt `manifestVersion: 2`, internen HTTP-Port 3000 und `/healthz`.
`localstorage.sqlite.paths` nennt `/app/data/sendewerk.sqlite`, damit Cloudron
eine konsistente Sicherung der aktiven SQLite-Datei erstellen kann. Das
Startskript stellt Verzeichnisse und Besitzrechte her, migriert als Benutzer
`cloudron` und ersetzt sich zuletzt per `exec` durch den Node-Prozess.
Das Image ist auf die am 21.10.2026 offiziell dokumentierte datierte Node-24-
Basis samt Digest gepinnt. Außer `/app/data`, `/run` und `/tmp` wird das
Laufzeitdateisystem als read-only behandelt.
Quellen:
- <https://docs.cloudron.io/packaging/>
- <https://docs.cloudron.io/packaging/manifest/>
- <https://docs.cloudron.io/packaging/addons/#localstorage>
- <https://docs.cloudron.io/packaging/cheat-sheet/>
## D-005: Theme ohne serverseitiges Nutzerkonto
Etappe 1 speichert `light`, `dark` oder `system` in `localStorage`. Ein kurzes
Inline-Skript in `app.html` setzt das resultierende Theme vor dem ersten Paint
auf dem Wurzelelement. Dies vermeidet einen falschen Theme-Blitz und benötigt
vor Etappe 2 weder Cookie noch Konto. Die Einstellung bleibt bei Neuladen im
gleichen Browser erhalten.
## D-006: Etappe-1-Vorschauen statt Attrappen
Vorschauseiten lesen echte persistente SQLite-Seed-Daten, bieten aber keine
funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis
grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird
keine noch nicht implementierte Funktion als fertig dargestellt.
+43
View File
@@ -0,0 +1,43 @@
# Projektstatus
## Aktueller Umfang
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und
lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1
- SvelteKit-/TypeScript-Monolith mit Node-Adapter
- konfigurierbare lokale SQLite- und Uploadpfade
- wiederholbare Migrationen und realistischer Seed
- persistentes Light-/Dark-/System-Theme
- responsive Navigation und gekennzeichnete Vorschauen für Fundus,
Sendungsbau und Live-Modus
- Healthcheck, Produktionsstart und frühes Cloudron-Paket
- getrennte Unit-/Integrations- und Browser-Testdaten
Die Vorschauen lesen echte Daten aus SQLite. Suche und mobile Pane-Umschaltung
funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den
späteren Etappen vorbehalten.
## Nicht Bestandteil dieser Etappe
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
werden.
## Bekannte offene Betriebsprüfung
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
und read-only Laufzeit konnte ohne bereitgestellten Cloudron-Server nicht
durchgeführt werden. Auch der lokale Container-Smoke-Test blieb offen, weil
auf der Prüfmaschine kein Docker-Daemon lief. Manifestkernfelder und
Startskript-Syntax wurden lokal geprüft.
## Verifizierte Umgebung
- Node.js 22.23.1, npm 10.9.8
- SvelteKit 3.1.0, Svelte 5.57.2, Vite 8.3.5
- SQLite-Treiber `better-sqlite3` 13.0.3
- Playwright 1.64.1 mit Chromium 156.0.8078.4 (Fallback-Build für Ubuntu 24.04)
+32
View File
@@ -0,0 +1,32 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: { 'no-undef': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
}
);
+96
View File
@@ -0,0 +1,96 @@
CREATE TABLE section_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL CHECK (sort_order >= 0)
);
CREATE TABLE formats (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE topics (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE pieces (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
format_id TEXT REFERENCES formats(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ('inbox', 'preparing', 'ready', 'archived')),
duration_seconds INTEGER NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0),
intro TEXT NOT NULL DEFAULT '',
moderation_notes TEXT NOT NULL DEFAULT '',
transition_text TEXT NOT NULL DEFAULT '',
presentation_text TEXT NOT NULL DEFAULT '',
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE piece_topics (
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
PRIMARY KEY (piece_id, topic_id)
);
CREATE TABLE piece_category_suggestions (
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
category_id TEXT NOT NULL REFERENCES section_categories(id) ON DELETE CASCADE,
PRIMARY KEY (piece_id, category_id)
);
CREATE TABLE piece_sources (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
label TEXT NOT NULL,
url TEXT NOT NULL,
timecode_seconds INTEGER CHECK (timecode_seconds IS NULL OR timecode_seconds >= 0),
position INTEGER NOT NULL CHECK (position >= 0)
);
CREATE TABLE shows (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
starts_at_utc TEXT NOT NULL,
twitch_title TEXT NOT NULL DEFAULT '',
announcement_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'live', 'completed')),
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE show_sections (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
explicit_duration_seconds INTEGER CHECK (
explicit_duration_seconds IS NULL OR explicit_duration_seconds >= 0
),
UNIQUE (show_id, position)
);
CREATE TABLE piece_usages (
id TEXT PRIMARY KEY,
show_section_id TEXT NOT NULL REFERENCES show_sections(id) ON DELETE CASCADE,
original_piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL,
position INTEGER NOT NULL CHECK (position >= 0),
status TEXT NOT NULL CHECK (status IN ('open', 'current', 'done', 'skipped')),
duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0),
title_snapshot TEXT NOT NULL,
intro_snapshot TEXT NOT NULL DEFAULT '',
moderation_snapshot TEXT NOT NULL DEFAULT '',
transition_snapshot TEXT NOT NULL DEFAULT '',
presentation_snapshot TEXT NOT NULL DEFAULT '',
is_reserve INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve IN (0, 1)),
UNIQUE (show_section_id, position)
);
CREATE INDEX idx_pieces_status ON pieces(status);
CREATE INDEX idx_shows_status_start ON shows(status, starts_at_utc);
CREATE INDEX idx_sections_show ON show_sections(show_id, position);
CREATE INDEX idx_usages_section ON piece_usages(show_section_id, position);
+3230
View File
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
{
"name": "sendewerk",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "npm run db:migrate && node --env-file-if-exists=.env build",
"setup": "npm run db:migrate && npm run db:seed",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
"db:seed": "node --env-file-if-exists=.env scripts/seed.mjs",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:e2e": "playwright test"
},
"dependencies": {
"better-sqlite3": "^13.0.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^3.1.0",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"svelte": "^5.57.2",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.3.5",
"vitest": "^5.0.5"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from '@playwright/test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const dataDirectory = mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
export default defineConfig({
testDir: 'tests',
testMatch: '**/*.e2e.ts',
fullyParallel: false,
workers: 1,
webServer: {
command: 'npm run setup && npm run build && npm start',
url: 'http://127.0.0.1:4173/healthz',
timeout: 120_000,
reuseExistingServer: false,
env: {
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'),
HOST: '127.0.0.1',
PORT: '4173'
}
},
use: {
baseURL: 'http://127.0.0.1:4173',
browserName: 'chromium',
trace: 'retain-on-failure'
}
});
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('prettier').Config} */
export default {
useTabs: true,
singleQuote: true,
trailingComma: 'none',
printWidth: 100,
plugins: ['prettier-plugin-svelte'],
overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }]
};
+12
View File
@@ -0,0 +1,12 @@
import path from 'node:path';
function configuredPath(value, fallback) {
return path.resolve(process.cwd(), value || fallback);
}
export function getPaths() {
return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
};
}
+57
View File
@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getPaths } from './config.mjs';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function migrate(databasePath = getPaths().database) {
mkdirSync(path.dirname(databasePath), { recursive: true });
mkdirSync(getPaths().uploads, { recursive: true });
const database = new Database(databasePath);
database.pragma('foreign_keys = ON');
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at_utc TEXT NOT NULL
)
`);
const applied = new Set(
database
.prepare('SELECT name FROM schema_migrations')
.all()
.map((row) => row.name)
);
const migrations = readdirSync(path.join(projectRoot, 'migrations'))
.filter((name) => /^\d+.+\.sql$/.test(name))
.sort();
for (const name of migrations) {
if (applied.has(name)) continue;
const sql = readFileSync(path.join(projectRoot, 'migrations', name), 'utf8');
database.exec('BEGIN IMMEDIATE');
try {
database.exec(sql);
database
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(name, new Date().toISOString());
database.exec('COMMIT');
console.log(`Migration applied: ${name}`);
} catch (error) {
database.exec('ROLLBACK');
database.close();
throw error;
}
}
database.close();
console.log(`Database ready: ${databasePath}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
migrate();
}
+286
View File
@@ -0,0 +1,286 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getPaths } from './config.mjs';
export function seed(databasePath = getPaths().database) {
mkdirSync(path.dirname(databasePath), { recursive: true });
const database = new Database(databasePath);
database.pragma('foreign_keys = ON');
const seedDatabase = database.transaction(() => {
const replace = (table, columns, rows) => {
const placeholders = columns.map(() => '?').join(', ');
const update = columns
.filter((column) => column !== 'id')
.map((column) => `${column} = excluded.${column}`)
.join(', ');
const statement = database.prepare(
`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})
ON CONFLICT(id) DO UPDATE SET ${update}`
);
for (const row of rows) statement.run(...row);
};
replace(
'section_categories',
['id', 'name', 'color', 'sort_order'],
[
['cat-jc', 'JC', '#67DDE8', 0],
['cat-reaction', 'Reaction', '#E7B957', 1],
['cat-game', 'Spiel', '#9B8AFB', 2]
]
);
replace(
'formats',
['id', 'name'],
[
['format-find', 'Fundstück'],
['format-talk', 'Gespräch'],
['format-quiz', 'Quiz']
]
);
replace(
'topics',
['id', 'name'],
[
['topic-gaming', 'Gaming'],
['topic-film', 'Film & Serien'],
['topic-internet', 'Netzkultur']
]
);
const timestamp = '2026-10-21T10:00:00.000Z';
replace(
'pieces',
[
'id',
'title',
'summary',
'format_id',
'status',
'duration_seconds',
'intro',
'moderation_notes',
'transition_text',
'presentation_text',
'created_at_utc',
'updated_at_utc'
],
[
[
'piece-console',
'Die kleine Konsole mit dem großen Dreh',
'Ein ungewöhnliches Handheld-Konzept sorgt für Diskussionsstoff.',
'format-find',
'ready',
480,
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.',
timestamp,
timestamp
],
[
'piece-effects',
'Warum praktische Effekte wieder auffallen',
'Neue Produktionen mischen Miniaturen, Masken und digitale Nacharbeit.',
'format-talk',
'ready',
720,
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.',
timestamp,
timestamp
],
[
'piece-quiz',
'Drei Sounds, ein Spiel',
'Kurzes Audioquiz mit bekannten Inventar- und Menüklängen.',
'format-quiz',
'preparing',
600,
'Augen zu: Erkennt ihr das Spiel nur an seinem Inventar?',
'Drei Runden, Auflösung jeweils erst nach den Tipps.',
'Damit sind die Ohren warm für unser erstes Spiel.',
'Welches Spiel steckt hinter diesem Klang?',
timestamp,
timestamp
],
[
'piece-pixels',
'Pixelart, die nicht nostalgisch sein will',
'Wie moderne Spiele Pixel als Material statt als Rückblick einsetzen.',
'format-find',
'ready',
540,
'Pixel sind längst keine technische Notlösung mehr.',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.',
timestamp,
timestamp
]
]
);
database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare(
'INSERT INTO piece_topics (piece_id, topic_id) VALUES (?, ?)'
);
for (const values of [
['piece-console', 'topic-gaming'],
['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'],
['piece-pixels', 'topic-gaming'],
['piece-pixels', 'topic-internet']
])
addTopic.run(...values);
database.prepare('DELETE FROM piece_category_suggestions WHERE piece_id LIKE ?').run('piece-%');
const addCategory = database.prepare(
'INSERT INTO piece_category_suggestions (piece_id, category_id) VALUES (?, ?)'
);
for (const values of [
['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'],
['piece-pixels', 'cat-reaction']
])
addCategory.run(...values);
replace(
'piece_sources',
['id', 'piece_id', 'label', 'url', 'timecode_seconds', 'position'],
[
[
'source-console',
'piece-console',
'Herstellerseite',
'https://example.com/console',
null,
0
],
[
'source-effects',
'piece-effects',
'Werkstattbericht',
'https://example.com/effects',
95,
0
],
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0]
]
);
replace(
'shows',
[
'id',
'title',
'starts_at_utc',
'twitch_title',
'announcement_text',
'status',
'created_at_utc',
'updated_at_utc'
],
[
[
'show-october',
'Herbstsendung: Kleine Welten',
'2026-10-23T18:00:00.000Z',
'Kleine Welten, große Ideen | heinzlive',
'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.',
'ready',
timestamp,
timestamp
]
]
);
replace(
'show_sections',
['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'],
[
['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300],
['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null],
['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600]
]
);
replace(
'piece_usages',
[
'id',
'show_section_id',
'original_piece_id',
'position',
'status',
'duration_seconds',
'title_snapshot',
'intro_snapshot',
'moderation_snapshot',
'transition_snapshot',
'presentation_snapshot',
'is_reserve'
],
[
[
'usage-console',
'section-stories',
'piece-console',
0,
'current',
480,
'Die kleine Konsole mit dem großen Dreh',
'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?',
'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.',
'Von der kleinen Konsole geht es zur ganz großen Leinwand.',
'Weniger Leistung, mehr Idee.',
0
],
[
'usage-effects',
'section-stories',
'piece-effects',
1,
'open',
720,
'Warum praktische Effekte wieder auffallen',
'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.',
'Beispiele vergleichen, nicht analog gegen digital ausspielen.',
'Nach so viel Handarbeit wird es spielerisch.',
'Wenn das Licht wirklich auf ein Modell fällt.',
0
],
[
'usage-reserve',
'section-stories',
'piece-pixels',
2,
'open',
540,
'Pixelart, die nicht nostalgisch sein will',
'Pixel sind längst keine technische Notlösung mehr.',
'Großaufnahmen vorbereiten, drei Stilmittel benennen.',
'Genug Theorie, jetzt spielen wir selbst.',
'Pixel als bewusste Entscheidung.',
1
]
]
);
});
seedDatabase();
database.close();
console.log(`Example data ready: ${databasePath}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
seed();
}
+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>
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -eu
mkdir -p /app/data/uploads
chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-3000}"
gosu cloudron:cloudron node /app/code/scripts/migrate.mjs
exec gosu cloudron:cloudron node /app/code/build
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+86
View File
@@ -0,0 +1,86 @@
import { expect, test } from '@playwright/test';
test('desktop fundus is usable, filterable and keeps its theme', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Gute Stücke, sofort griffbereit.' })
).toBeVisible();
await expect(page.getByText('Gestaltungsvorschau: Fundus')).toBeVisible();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Pixelart, die nicht nostalgisch sein will' })
).toBeVisible();
await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop.png'), fullPage: true });
});
test('desktop builder shows fundus and run side by side', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await expect(page.getByText('Gestaltungsvorschau: Sendungsbau')).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
const boxes = await Promise.all([
page.locator('.source-pane').boundingBox(),
page.locator('.run-pane').boundingBox()
]);
expect(boxes[0]?.x).toBeLessThan(boxes[1]?.x ?? 0);
});
test('mobile builder switches panes without horizontal scrolling', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen');
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('live preview exposes readable prompts and working text controls', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live');
await expect(page.getByText('Gestaltungsvorschau: Live-Modus')).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page
.locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
const after = await page
.locator('.live-current')
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
expect(after).not.toBe(before);
await page.getByText('Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "$app/tsconfig",
"compilerOptions": {
"strict": true,
"types": ["$app/types", "node"]
},
"include": ["src", "vite.config.ts", "playwright.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'vitest/config';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});