Initial release 0.1.0
This commit is contained in:
commit
9f97d8407a
52 files changed
+6774
No files matched your search
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.data
|
||||
.env
|
||||
.svelte-kit
|
||||
build
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,3 @@
|
||||
# Relative paths are resolved from the project directory.
|
||||
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
|
||||
SENDEWERK_UPLOAD_DIR=.data/uploads
|
||||
+19
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
package-lock.json
|
||||
test-results
|
||||
playwright-report
|
||||
Sendewerk-OpenCode-Bauplan.md
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
Generated
+3230
File diff suppressed because it is too large.
Load diff
@@ -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/*"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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' } }]
|
||||
};
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}[]
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(307, '/fundus');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getPieces } from '#lib/server/previews.js';
|
||||
|
||||
export function load() {
|
||||
return { pieces: getPieces() };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getPieces, getShow } from '#lib/server/previews.js';
|
||||
|
||||
export function load() {
|
||||
return { pieces: getPieces(), show: getShow() };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getTaxonomy } from '#lib/server/previews.js';
|
||||
|
||||
export function load() {
|
||||
return { taxonomy: getTaxonomy() };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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', '');
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "$app/tsconfig",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"types": ["$app/types", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
@@ -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}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user