1 Commits
Author SHA1 Message Date
hnzio 7590be66a5 Release 0.3.0 2026-10-21 21:05:57 +02:00
31 changed files with 3257 additions and 138 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
SENDEWERK_DB_PATH=.data/sendewerk.sqlite SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads SENDEWERK_UPLOAD_DIR=.data/uploads
SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox
BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env. # Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN= SENDEWERK_SETUP_TOKEN=
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.2.0", "version": "0.3.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+22 -6
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten, Etappe 1 bis 3: technisches Grundgerüst, Anmeldung und Rollen sowie einen
Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen. persistenten Fundus mit Eingang, Editor, Taxonomie, privaten Bildern und
Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen. Konfliktschutz. Sendungsbau und Live-Steuerung folgen in späteren Etappen.
## Voraussetzungen ## Voraussetzungen
@@ -46,6 +46,7 @@ enthält bewusst kein Konto und kein Standardpasswort.
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei | | `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis | | `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail | | `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `BODY_SIZE_LIMIT` | `6M` | Requestlimit für private Bild-Uploads bis 5 MB |
| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen | | `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen |
| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit | | `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit |
| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` | | `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` |
@@ -67,6 +68,21 @@ Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte
explizit gesetzt sind. explizit gesetzt sind.
## Inhalte und private Bilder
Neue Gedanken oder Links lassen sich im Eingang schnell erfassen. Im Fundus
stehen leere Pieces sowie Vorlagen für Gespräch, Reaction/Fundstück und
Interaktion bereit. Der Editor speichert Änderungen nach kurzer Pause
automatisch; ein sichtbarer Status meldet Erfolg, Fehler oder eine parallele
Änderung. In diesem Fall muss die Seite neu geladen werden, bevor erneut
gespeichert wird.
Markdown ist in Einleitung, Recherche, Moderation, Überleitung und Präsentation
möglich. Die Ausgabe wird serverseitig auf eine begrenzte Auswahl sicherer
Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müssen
als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten
Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar.
## Befehle ## Befehle
| Befehl | Zweck | | Befehl | Zweck |
@@ -142,6 +158,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet `better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind
MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen
den tatsächlich installierten Baum. unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
+25
View File
@@ -66,3 +66,28 @@
- Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck - Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck
und Migration vor Start erfolgreich und Migration vor Start erfolgreich
- `bash -n start.sh`: erfolgreich - `bash -n start.sh`: erfolgreich
## Etappe 3
| Kriterium | Nachweis | Stand |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------- |
| Anlegen, Bearbeiten, Suchen, Filtern und Reload | Browserrundlauf mit Vorlage, Autosave, Quellen, Status, Suche und Neuladen | erfüllt am 21.10.2026 |
| Taxonomien unabhängig | Format einzeln sowie Themen und Abschnittskategorien mehrfach gespeichert und gefiltert | erfüllt am 21.10.2026 |
| Sicheres Markdown | Verhaltenstest und Browservorschau entfernen Skript/JavaScript, Formatierung bleibt erhalten | erfüllt am 21.10.2026 |
| Private Bilder | Signatur-/Größenprüfung; anonymer Direktrequest erhält keinen Dateiinhalt | erfüllt am 21.10.2026 |
| Sichtbare Speicherfehler | Actionfehler und Autosave-Fehlerstatus bleiben am Formular sichtbar | erfüllt am 21.10.2026 |
| Konflikterkennung | zwei Browserseiten; veraltete Revision erhält Konflikt und überschreibt die neue Fassung nicht | erfüllt am 21.10.2026 |
| Archiv und Wiederherstellung | Browserrundlauf blendet Piece aus und stellt den vorherigen Status wieder her | erfüllt am 21.10.2026 |
| Duplikat | Verhaltenstest und Browser prüfen unabhängige Kopie samt Zuordnungen und Bild | erfüllt am 21.10.2026 |
## Ausgeführte Prüfungen Etappe 3
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 13 Tests in 3 Dateien erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 10 von 10 Browsertests erfolgreich
- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne
ungewollten horizontalen Überlauf
- Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert
- `bash -n start.sh` und `git diff --check`: erfolgreich
+28 -6
View File
@@ -37,10 +37,10 @@ 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. Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | | 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 | | Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | | 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 | | 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 | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
@@ -89,6 +89,28 @@ konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen. Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
## Inhalte, Markdown und Medien
Etappe 3 führt die Inhaltslogik in `src/lib/server/content.ts` zusammen. Listen
erhalten ausschließlich kompakte Piece-Daten; private Recherche und vollständige
Redaktionstexte werden nur an den angemeldeten Editor ausgeliefert. Themen und
Abschnittskategorien sind Mehrfachzuordnungen, das Format bleibt eine einzelne
unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
Jedes Piece besitzt eine ganzzahlige Revision. Speichern, Archivieren und
Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision.
Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch
Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die
Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien.
Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit
sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über
Markdown eingebettet. Uploads akzeptieren höchstens 5 MB und nur anhand ihrer
Dateisignatur erkannte JPEG-, PNG-, WebP- oder GIF-Dateien. Interne Dateinamen
sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]`
prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -108,7 +130,7 @@ Fachabläufe werden jedoch nicht vorgetäuscht.
## Sicherheitsgrenzen ab den Folgeetappen ## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt. betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen. kontrollierte Redirects.
+37
View File
@@ -135,3 +135,40 @@ Geheimnisträger und werden weder versioniert noch über eine Route ausgeliefert
Nodemailer 10.1.7 unterstützt Node 20 oder neuer und ist MIT-0-lizenziert. Nodemailer 10.1.7 unterstützt Node 20 oder neuer und ist MIT-0-lizenziert.
Quelle: <https://github.com/nodemailer/nodemailer> Quelle: <https://github.com/nodemailer/nodemailer>
## D-011: Form-Actions und optimistische Piece-Revisionen
Piece-Schreibvorgänge bleiben normale SvelteKit-Form-Actions und funktionieren
damit ohne clientseitiges JavaScript. `use:enhance` ergänzt den Editor um
verzögertes Autospeichern und sichtbare Zustände. Vor interner Navigation wird
eine ausstehende Änderung gespeichert; beim Verlassen der Seite greift die
Browserwarnung. Jede Mutation vergleicht eine erwartete ganzzahlige Revision in
derselben SQLite-Transaktion. Veraltete Änderungen liefern 409 und überschreiben
keine aktuelleren Inhalte oder Zuordnungen.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-012: Markdown immer nach dem Parsen bereinigen
Marked 18.1.1 rendert die gespeicherten Markdown-Texte serverseitig. Marked
weist ausdrücklich darauf hin, dass seine HTML-Ausgabe nicht bereinigt ist.
Darum reduziert sanitize-html 2.18.1 das Ergebnis anschließend auf ausgewählte
Text-, Listen-, Code- und Linkelemente. Skripte, Bilder, Ereignisattribute,
relative Protokoll-URLs und andere Schemata werden nicht zugelassen.
Quellen:
- <https://github.com/markedjs/marked>
- <https://github.com/apostrophecms/apostrophe/tree/main/packages/sanitize-html>
## D-013: Private Rasterbilder außerhalb statischer Assets
Uploads liegen ausschließlich im konfigurierten privaten Uploadverzeichnis und
werden über zufällige interne Namen adressiert. file-type 22.2.0 erkennt den Typ
aus dem Dateiinhalt; Client-MIME und Originalname entscheiden nicht über Typ oder
Pfad. Zugelassen sind JPEG, PNG, WebP und GIF bis 5 MB. SVG und allgemeine
Dokumente bleiben ausgeschlossen. Eine authentifizierte ID-Route liefert Bilder
mit sicherheitsrelevanten Headern aus. Das Adapter-Requestlimit liegt mit 6 MB
knapp oberhalb des fachlichen Dateilimits.
Quelle: <https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload>
+23 -8
View File
@@ -2,9 +2,9 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 3 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde Anmeldung, Rollen, persistente Inhaltsbearbeitung und lokaler Betriebsweg sind
nicht begonnen. dokumentiert. Etappe 4 wurde nicht begonnen.
## Umgesetzter Stand Etappe 1 ## Umgesetzter Stand Etappe 1
@@ -35,12 +35,11 @@ Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen. umgebrochen.
## Nicht Bestandteil dieser Etappe ## Noch nicht umgesetzt
Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung, Sendungsplanung, echte Live-Steuerung, Präsentationsfenster, Archivabschluss,
Präsentationsfenster, Archivabschluss, Freigaben, RSS und API. Freigaben, RSS und API. Die bestehenden Vorschauen dürfen nicht als
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden Implementierung dieser späteren Funktionen verstanden werden.
werden.
## Umgesetzter Stand Etappe 2 ## Umgesetzter Stand Etappe 2
@@ -53,6 +52,22 @@ werden.
- lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand - lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand
- responsive Authentifizierungs- und Kontenverwaltungsoberflächen - responsive Authentifizierungs- und Kontenverwaltungsoberflächen
## Umgesetzter Stand Etappe 3
- persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen
- Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang
- vollständiger Editor für Quellen, Dauer, Status und getrennte Einleitung,
Recherche, Moderation, Überleitung und Präsentation
- serverseitig bereinigte Markdown-Vorschau
- unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung
- serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt
- Autospeichern mit sichtbarem Zustand und Warten vor interner Navigation
- optimistische Revisionen verhindern unbemerktes Überschreiben
- Duplizieren einschließlich Quellen, Taxonomie und Bildern
- Archivieren und Wiederherstellen in den vorherigen Arbeitsstatus
- private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung
- erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen
## Bekannte offene Betriebsprüfung ## Bekannte offene Betriebsprüfung
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
+23
View File
@@ -0,0 +1,23 @@
ALTER TABLE pieces ADD COLUMN research_text TEXT NOT NULL DEFAULT '';
ALTER TABLE pieces ADD COLUMN revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1);
ALTER TABLE pieces ADD COLUMN archived_from_status TEXT CHECK (
archived_from_status IS NULL OR archived_from_status IN ('inbox', 'preparing', 'ready')
);
CREATE TABLE piece_media (
id TEXT PRIMARY KEY,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE CASCADE,
storage_name TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp', 'image/gif')),
byte_size INTEGER NOT NULL CHECK (byte_size > 0 AND byte_size <= 5242880),
alt_text TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL CHECK (position >= 0),
created_at_utc TEXT NOT NULL,
UNIQUE (piece_id, position)
);
CREATE INDEX idx_piece_media_piece ON piece_media(piece_id, position);
CREATE INDEX idx_piece_topics_topic ON piece_topics(topic_id, piece_id);
CREATE INDEX idx_piece_categories_category ON piece_category_suggestions(category_id, piece_id);
CREATE INDEX idx_piece_sources_piece ON piece_sources(piece_id, position);
+294 -10
View File
@@ -1,16 +1,19 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7" "file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -20,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.19.0",
@@ -37,6 +41,16 @@
"node": ">=22.17 <25" "node": ">=22.17 <25"
} }
}, },
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cacheable/memory": { "node_modules/@cacheable/memory": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -951,6 +965,29 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@tokenizer/inflate": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.5.1.tgz",
"integrity": "sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1010,6 +1047,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/sanitize-html": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.2.tgz",
"integrity": "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^12.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.0", "version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
@@ -1475,11 +1522,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -1527,6 +1579,85 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1538,7 +1669,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -1859,6 +1989,24 @@
"flat-cache": "^6.1.23" "flat-cache": "^6.1.23"
} }
}, },
"node_modules/file-type": {
"version": "22.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.2.0.tgz",
"integrity": "sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.5.1",
"strtok3": "^10.5.0",
"token-types": "^6.1.2",
"uint8array-extras": "^1.6.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -1956,6 +2104,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1999,6 +2189,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2047,6 +2246,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/launder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.23"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2365,6 +2573,18 @@
"@jridgewell/sourcemap-codec": "^1.6.0" "@jridgewell/sourcemap-codec": "^1.6.0"
} }
}, },
"node_modules/marked": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -2405,14 +2625,12 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -2516,6 +2734,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2540,7 +2764,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -2589,7 +2812,6 @@
"version": "8.5.31", "version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2853,6 +3075,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/sanitize-html": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.1.0",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"postcss": "^8.5.27"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -2908,7 +3147,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2921,6 +3159,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/strtok3": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.2.tgz",
"integrity": "sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.57.2", "version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -3099,6 +3353,24 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3173,6 +3445,18 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/uint8array-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -26,7 +26,10 @@
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7" "file-type": "^22.2.0",
"marked": "^18.1.1",
"nodemailer": "^10.1.7",
"sanitize-html": "^2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -36,6 +39,7 @@
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/sanitize-html": "^2.16.2",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.19.0",
+55 -3
View File
@@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) {
'Pixel als bewusste Entscheidung.', 'Pixel als bewusste Entscheidung.',
timestamp, timestamp,
timestamp timestamp
],
[
'piece-inbox-link',
'Indie-Showcase nachhören',
'Ein Fundstück aus dem letzten Showcase, das noch eingeordnet werden muss.',
'format-find',
'inbox',
0,
'',
'',
'',
'',
timestamp,
timestamp
],
[
'piece-archive-demo',
'Controller mit eingebautem Bildschirm',
'Ein früheres Fundstück, das bewusst im Archiv liegt.',
'format-find',
'archived',
300,
'',
'',
'',
'',
timestamp,
timestamp
] ]
] ]
); );
database
.prepare('UPDATE pieces SET research_text = ? WHERE id = ?')
.run(
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten',
'piece-console'
);
database
.prepare('UPDATE pieces SET presentation_text = ? WHERE id = ?')
.run(
'## Weniger Leistung, mehr Idee\n\nDie Kurbel ist Teil des Spiels, nicht nur Dekoration.',
'piece-console'
);
database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%'); database.prepare('DELETE FROM piece_topics WHERE piece_id LIKE ?').run('piece-%');
const addTopic = database.prepare( const addTopic = database.prepare(
@@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) {
['piece-effects', 'topic-film'], ['piece-effects', 'topic-film'],
['piece-quiz', 'topic-gaming'], ['piece-quiz', 'topic-gaming'],
['piece-pixels', 'topic-gaming'], ['piece-pixels', 'topic-gaming'],
['piece-pixels', 'topic-internet'] ['piece-pixels', 'topic-internet'],
['piece-inbox-link', 'topic-gaming'],
['piece-archive-demo', 'topic-gaming']
]) ])
addTopic.run(...values); addTopic.run(...values);
@@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) {
['piece-console', 'cat-jc'], ['piece-console', 'cat-jc'],
['piece-effects', 'cat-reaction'], ['piece-effects', 'cat-reaction'],
['piece-quiz', 'cat-jc'], ['piece-quiz', 'cat-jc'],
['piece-pixels', 'cat-reaction'] ['piece-pixels', 'cat-reaction'],
['piece-inbox-link', 'cat-reaction'],
['piece-archive-demo', 'cat-reaction']
]) ])
addCategory.run(...values); addCategory.run(...values);
@@ -173,7 +217,15 @@ export function seed(databasePath = getPaths().database) {
95, 95,
0 0
], ],
['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0] ['source-pixels', 'piece-pixels', 'Galerie', 'https://example.com/pixels', null, 0],
[
'source-inbox-showcase',
'piece-inbox-link',
'Showcase-Aufzeichnung',
'https://example.com/showcase',
120,
0
]
] ]
); );
+611 -2
View File
@@ -79,7 +79,8 @@ body {
button, button,
input, input,
select { select,
textarea {
font: inherit; font: inherit;
} }
@@ -1291,7 +1292,9 @@ a {
background: transparent; background: transparent;
} }
.secondary-button:disabled { .primary-button:disabled,
.secondary-button:disabled,
.text-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
@@ -1464,6 +1467,560 @@ a {
margin-top: 1rem; margin-top: 1rem;
} }
.editable-taxonomy span {
padding-right: 0.25rem;
}
.editable-taxonomy form {
display: inline-flex;
}
.editable-taxonomy button {
display: grid;
width: 1.25rem;
height: 1.25rem;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.taxonomy-form {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.75rem;
}
.taxonomy-form label:first-of-type {
min-width: 0;
flex: 1;
}
.taxonomy-form input:not([type='color']) {
width: 100%;
min-height: 2.25rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-strong);
color: var(--text);
}
.taxonomy-form input[type='color'] {
width: 2.3rem;
height: 2.25rem;
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-strong);
}
.template-picker {
margin-bottom: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.template-picker summary {
padding: 0.75rem 0.9rem;
cursor: pointer;
font-size: 0.8rem;
font-weight: 720;
}
.template-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
padding: 0 0.75rem 0.75rem;
}
.template-grid button {
display: grid;
width: 100%;
min-height: 5rem;
gap: 0.2rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
text-align: left;
}
.template-grid button span {
color: var(--text-muted);
font-size: 0.7rem;
line-height: 1.35;
}
.library-filters {
display: grid;
grid-template-columns: minmax(15rem, 1.5fr) repeat(4, minmax(7rem, 0.65fr)) auto auto;
align-items: end;
gap: 0.55rem;
margin-bottom: 0.75rem;
}
.library-filters > label:not(.search-field) {
display: grid;
gap: 0.2rem;
color: var(--text-muted);
font-size: 0.62rem;
font-weight: 700;
}
.library-filters select {
min-width: 0;
height: 2.5rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--panel);
color: var(--text);
}
.text-link {
color: var(--accent);
font-size: 0.72rem;
font-weight: 720;
}
.piece-card {
grid-template-columns: auto minmax(0, 1.65fr) minmax(8rem, 0.85fr) minmax(9rem, auto) auto;
}
.piece-thumbnail {
width: 3.8rem;
height: 3.8rem;
border-radius: 0.72rem;
object-fit: cover;
}
.piece-thumbnail-placeholder {
display: grid;
place-items: center;
background: var(--panel-soft);
color: var(--text-faint);
font-size: 0.75rem;
font-weight: 800;
}
.piece-primary h2 a:hover {
color: var(--accent);
}
.piece-actions {
display: grid;
justify-items: end;
gap: 0.22rem;
}
.danger-text {
color: var(--danger) !important;
}
.capture-panel {
padding: 1rem;
}
.capture-panel h2,
.inbox-list h2 {
margin: 0.18rem 0 0.8rem;
font-size: 1rem;
}
.capture-form,
.upload-form {
display: grid;
grid-template-columns: minmax(12rem, 1fr) minmax(12rem, 1fr) auto;
align-items: end;
gap: 0.65rem;
}
.capture-form label,
.upload-form label {
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.capture-form label span {
color: var(--text-faint);
font-weight: 500;
}
.capture-form input,
.upload-form input {
width: 100%;
min-height: 2.6rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
}
.inbox-list {
margin-top: 1rem;
}
.inbox-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.76rem;
}
.inbox-item > span:first-child {
display: grid;
}
.inbox-item small {
color: var(--text-muted);
}
.archive-list {
display: grid;
gap: 0.65rem;
}
.archive-list article {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.archive-list h2 {
margin: 0;
font-size: 0.92rem;
}
.archive-list p {
margin: 0.15rem 0 0;
color: var(--text-muted);
font-size: 0.74rem;
}
.editor-page {
max-width: 82rem;
}
.editor-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.editor-header h1 {
margin: 0.25rem 0 0;
font-size: clamp(1.5rem, 2.4vw, 2.2rem);
letter-spacing: -0.035em;
}
.back-link {
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
.save-indicator {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
}
.save-indicator i {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: var(--accent);
}
.save-indicator.problem,
.editor-savebar .problem {
color: var(--danger);
}
.save-indicator.problem i {
background: var(--danger);
}
.piece-editor {
display: grid;
gap: 0.75rem;
}
.editor-section {
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
}
.section-title {
margin-bottom: 0.8rem;
}
.section-title h2 {
margin: 0.15rem 0 0;
font-size: 1rem;
}
.section-title small {
color: var(--text-muted);
font-size: 0.68rem;
}
.split-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.editor-fields,
.markdown-grid {
display: grid;
gap: 0.65rem;
}
.two-columns,
.markdown-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide-field {
grid-column: 1 / -1;
}
.editor-fields label,
.markdown-grid > label,
.source-row label {
display: grid;
min-width: 0;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.editor-fields input,
.editor-fields select,
.editor-fields textarea,
.markdown-grid textarea,
.source-row input {
width: 100%;
min-width: 0;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--panel-strong);
color: var(--text);
}
.editor-fields input,
.editor-fields select,
.source-row input {
min-height: 2.6rem;
}
.editor-fields textarea,
.markdown-grid textarea {
resize: vertical;
}
.taxonomy-editor fieldset {
margin: 0 0 0.75rem;
padding: 0;
border: 0;
}
.taxonomy-editor legend {
margin-bottom: 0.4rem;
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.choice-grid {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.choice-grid label {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.38rem 0.55rem;
border: 1px solid var(--line);
border-radius: 2rem;
font-size: 0.7rem;
}
.choice-grid i {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--choice-color);
}
.private-field {
padding: 0.65rem;
border: 1px solid color-mix(in srgb, var(--gold) 42%, var(--line));
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--gold) 6%, var(--panel));
}
.markdown-preview {
min-height: 3.2rem;
padding: 0.65rem;
border: 1px dashed var(--line);
border-radius: var(--radius-small);
color: var(--text-muted);
font-size: 0.76rem;
font-weight: 400;
line-height: 1.55;
overflow-wrap: anywhere;
}
.markdown-preview > :first-child {
margin-top: 0;
}
.markdown-preview > :last-child {
margin-bottom: 0;
}
.markdown-preview a {
color: var(--accent);
text-decoration: underline;
}
.markdown-preview pre {
overflow-x: auto;
padding: 0.5rem;
border-radius: 0.4rem;
background: var(--panel-soft);
}
.source-list {
display: grid;
gap: 0.55rem;
}
.source-row {
display: grid;
grid-template-columns: minmax(8rem, 0.65fr) minmax(14rem, 1.5fr) minmax(8rem, 0.6fr) auto;
align-items: end;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line);
}
.editor-savebar {
position: sticky;
z-index: 5;
bottom: 0.75rem;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.editor-savebar span {
color: var(--text-muted);
font-size: 0.7rem;
}
.media-editor {
margin-top: 0.75rem;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.media-grid figure {
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel-soft);
}
.media-grid img {
display: block;
width: 100%;
height: 8rem;
object-fit: cover;
}
.media-grid figcaption {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.45rem;
font-size: 0.65rem;
}
.editor-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
}
@media (max-width: 72rem) {
.library-filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.library-filters .search-field {
grid-column: 1 / -1;
}
.piece-card {
grid-template-columns: auto minmax(0, 1fr) minmax(8rem, 0.7fr) auto;
}
.piece-state {
grid-column: 2 / 4;
align-items: flex-start;
}
.source-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 72rem) { @media (max-width: 72rem) {
.page-wrap { .page-wrap {
padding-inline: 2rem; padding-inline: 2rem;
@@ -1575,6 +2132,44 @@ a {
.page-heading h1 { .page-heading h1 {
font-size: 1.75rem; font-size: 1.75rem;
} }
.template-grid,
.two-columns,
.markdown-grid,
.library-filters,
.capture-form,
.upload-form,
.source-row {
grid-template-columns: 1fr;
}
.library-filters .search-field,
.wide-field {
grid-column: auto;
}
.editor-header {
align-items: flex-start;
flex-direction: column;
}
.editor-section {
padding: 0.85rem;
border-radius: var(--radius-medium);
}
.editor-savebar {
bottom: calc(var(--mobile-nav) + 0.6rem);
justify-content: space-between;
}
.split-section-title,
.archive-list article {
align-items: stretch;
flex-direction: column;
}
.editor-actions {
align-items: stretch;
flex-direction: column;
}
.editor-actions form,
.editor-actions button {
width: 100%;
}
.show-heading, .show-heading,
.live-header { .live-header {
align-items: flex-start; align-items: flex-start;
@@ -1613,6 +2208,13 @@ a {
border-radius: var(--radius-medium); border-radius: var(--radius-medium);
background: var(--panel); background: var(--panel);
} }
.piece-thumbnail {
width: 100%;
height: 8rem;
}
.piece-thumbnail-placeholder {
display: none;
}
.piece-card:last-child { .piece-card:last-child {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
@@ -1641,6 +2243,13 @@ a {
.piece-facts { .piece-facts {
justify-content: flex-start; justify-content: flex-start;
} }
.piece-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
gap: 0.7rem;
}
.show-summary { .show-summary {
width: 100%; width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
@@ -0,0 +1,7 @@
<script lang="ts">
let { html }: { html: string } = $props();
</script>
<!-- html is produced exclusively by the allowlist sanitizer in $lib/server/markdown.ts. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="markdown-preview">{@html html}</div>
+169
View File
@@ -0,0 +1,169 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createPiece,
createTaxonomy,
deletePieceImage,
deleteTaxonomy,
duplicatePiece,
getMediaFile,
getPiece,
getTaxonomy,
listPieces,
savePiece,
setPieceArchived,
uploadPieceImage,
type PieceInput
} from './content.js';
import { closeDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
return {
title: 'Getestetes Piece',
summary: 'Eine persistente Kurzbeschreibung.',
formatId: 'format-talk',
status: 'ready',
durationSeconds: 420,
intro: '**Starker Einstieg**',
researchText: 'Private Recherche',
moderationNotes: '- Punkt eins',
transitionText: 'Weiter geht es.',
presentationText: '## Sichtbare Aussage',
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction'],
sources: [
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
],
...overrides
};
}
describe('content management', () => {
it('persists complete pieces and rejects stale revisions atomically', () => {
const id = createPiece('talk');
const initial = getPiece(id)!;
const saved = savePiece(id, initial.revision, completeInput());
const piece = getPiece(id)!;
expect(saved.revision).toBe(2);
expect(piece).toMatchObject({
title: 'Getestetes Piece',
status: 'ready',
researchText: 'Private Recherche',
topicIds: ['topic-gaming', 'topic-internet'],
categoryIds: ['cat-jc', 'cat-reaction']
});
expect(piece.sources).toEqual([
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
]);
expect(() =>
savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] }))
).toThrow('CONFLICT');
expect(getPiece(id)).toMatchObject({
title: 'Getestetes Piece',
topicIds: ['topic-gaming', 'topic-internet']
});
});
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
const sourceId = createPiece('find');
const source = getPiece(sourceId)!;
savePiece(
sourceId,
source.revision,
completeInput({
title: 'Seltene Hardware',
formatId: 'format-find',
topicIds: [topic.id],
categoryIds: ['cat-reaction']
})
);
expect(
listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id })
).toHaveLength(1);
expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0);
expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE');
createTaxonomy('topic', 'Unbenutzt');
const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!;
deleteTaxonomy('topic', unused.id);
expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false);
expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE');
const duplicateId = await duplicatePiece(sourceId);
expect(getPiece(duplicateId)).toMatchObject({
title: 'Kopie von Seltene Hardware',
status: 'preparing',
topicIds: [topic.id]
});
const current = getPiece(sourceId)!;
setPieceArchived(sourceId, current.revision, true);
expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false);
const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!;
setPieceArchived(sourceId, archived.revision, false);
expect(getPiece(sourceId)?.status).toBe('ready');
});
it('sanitizes dangerous markdown while preserving useful formatting', () => {
const html = renderMarkdown(
'# Verbotene Ebene\n\n**sicher** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
);
expect(html).toContain('<strong>sicher</strong>');
expect(html).not.toContain('<script');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('<h1');
});
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await uploadPieceImage(
id,
1,
new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel'
);
const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
await expect(
uploadPieceImage(
id,
piece.revision,
new File(['<script>alert(1)</script>'], 'angriff.png', { type: 'image/png' }),
''
)
).rejects.toThrow('INVALID_IMAGE_TYPE');
await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0);
});
});
+803
View File
@@ -0,0 +1,803 @@
import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';
import { getDatabase } from './database.js';
import { getServerPaths } from './config.js';
export const PIECE_STATUSES = ['inbox', 'preparing', 'ready', 'archived'] as const;
export type PieceStatus = (typeof PIECE_STATUSES)[number];
export type EditablePieceStatus = Exclude<PieceStatus, 'archived'>;
export type TaxonomyKind = 'category' | 'format' | 'topic';
export type PieceSourceInput = {
label: string;
url: string;
timecodeSeconds: number | null;
};
export type PieceInput = {
title: string;
summary: string;
formatId: string | null;
status: EditablePieceStatus;
durationSeconds: number;
intro: string;
researchText: string;
moderationNotes: string;
transitionText: string;
presentationText: string;
topicIds: string[];
categoryIds: string[];
sources: PieceSourceInput[];
};
export type PieceListItem = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
durationSeconds: number;
formatId: string | null;
format: string;
categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[];
sourceCount: number;
mediaId: string | null;
};
export type PieceEditor = Omit<PieceInput, 'status'> & {
id: string;
status: PieceStatus;
revision: number;
createdAtUtc: string;
updatedAtUtc: string;
media: PieceMedia[];
};
export type PieceMedia = {
id: string;
originalName: string;
mimeType: string;
byteSize: number;
altText: string;
position: number;
};
export type Taxonomy = {
categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[];
topics: { id: string; name: string }[];
};
export const pieceTemplates = [
{
id: 'blank',
name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.'
},
{
id: 'talk',
name: 'Gespräch',
description: 'Leitfragen, Recherche und eine klare Überleitung.'
},
{
id: 'find',
name: 'Reaction / Fundstück',
description: 'Quelle, Einordnung und präsentierbare Kernaussage.'
},
{
id: 'interaction',
name: 'Interaktion',
description: 'Quiz, Publikumsfrage oder kleines Spiel.'
}
] as const;
export type PieceTemplateId = (typeof pieceTemplates)[number]['id'];
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGES = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
['image/gif', 'gif']
]);
function cleanText(value: unknown, max: number, field: string): string {
const text = String(value ?? '').trim();
if (text.length > max) throw new Error(`INVALID_${field.toUpperCase()}`);
return text;
}
function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function validUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories', ids: string[]) {
if (!ids.length) return;
const placeholders = ids.map(() => '?').join(', ');
const count = getDatabase()
.prepare(`SELECT count(*) AS count FROM ${table} WHERE id IN (${placeholders})`)
.get(...ids) as { count: number };
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
}
function validatePieceInput(input: PieceInput): PieceInput {
const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
if (
!Number.isInteger(input.durationSeconds) ||
input.durationSeconds < 0 ||
input.durationSeconds > 86_400
) {
throw new Error('INVALID_DURATION');
}
const topicIds = uniqueIds(input.topicIds);
const categoryIds = uniqueIds(input.categoryIds);
if (topicIds.length > 20 || categoryIds.length > 20) throw new Error('INVALID_TAXONOMY');
if (input.formatId) requireTaxonomyIds('formats', [input.formatId]);
requireTaxonomyIds('topics', topicIds);
requireTaxonomyIds('section_categories', categoryIds);
if (input.sources.length > 20) throw new Error('INVALID_SOURCES');
const sources = input.sources.map((source) => {
const label = cleanText(source.label, 200, 'source');
const url = cleanText(source.url, 2_000, 'source');
if (!label || !validUrl(url)) throw new Error('INVALID_SOURCES');
if (
source.timecodeSeconds !== null &&
(!Number.isInteger(source.timecodeSeconds) ||
source.timecodeSeconds < 0 ||
source.timecodeSeconds > 86_400)
) {
throw new Error('INVALID_SOURCES');
}
return { label, url, timecodeSeconds: source.timecodeSeconds };
});
return {
title,
summary: cleanText(input.summary, 2_000, 'summary'),
formatId: input.formatId || null,
status: input.status,
durationSeconds: input.durationSeconds,
intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'),
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
topicIds,
categoryIds,
sources
};
}
export function getTaxonomy(): Taxonomy {
const database = getDatabase();
return {
categories: database
.prepare('SELECT id, name, color FROM section_categories ORDER BY sort_order, name')
.all() as Taxonomy['categories'],
formats: database
.prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'],
topics: database
.prepare('SELECT id, name FROM topics ORDER BY name')
.all() as Taxonomy['topics']
};
}
type PieceListRow = {
id: string;
title: string;
summary: string;
status: PieceStatus;
revision: number;
duration_seconds: number;
format_id: string | null;
format: string;
source_count: number;
media_id: string | null;
};
export function listPieces(filters: {
statuses: PieceStatus[];
query?: string;
formatId?: string;
topicId?: string;
categoryId?: string;
}): PieceListItem[] {
if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`];
const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase();
if (query) {
const escapedQuery = query
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
conditions.push(`(
lower(p.title) LIKE ? ESCAPE '\\' OR lower(p.summary) LIKE ? ESCAPE '\\' OR
EXISTS (SELECT 1 FROM piece_sources ps WHERE ps.piece_id = p.id AND (lower(ps.label) LIKE ? ESCAPE '\\' OR lower(ps.url) LIKE ? ESCAPE '\\')) OR
EXISTS (SELECT 1 FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id WHERE pt.piece_id = p.id AND lower(t.name) LIKE ? ESCAPE '\\')
)`);
parameters.push(...Array(5).fill(`%${escapedQuery}%`));
}
if (filters.formatId) {
conditions.push('p.format_id = ?');
parameters.push(filters.formatId);
}
if (filters.topicId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_topics pt WHERE pt.piece_id = p.id AND pt.topic_id = ?)'
);
parameters.push(filters.topicId);
}
if (filters.categoryId) {
conditions.push(
'EXISTS (SELECT 1 FROM piece_category_suggestions pc WHERE pc.piece_id = p.id AND pc.category_id = ?)'
);
parameters.push(filters.categoryId);
}
const database = getDatabase();
const rows = database
.prepare(
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format,
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count,
(SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')}
ORDER BY CASE p.status WHEN 'ready' THEN 0 WHEN 'preparing' THEN 1 WHEN 'inbox' THEN 2 ELSE 3 END,
p.updated_at_utc DESC, p.title`
)
.all(...parameters) as PieceListRow[];
const categoryStatement = database.prepare(
`SELECT c.id, c.name, c.color FROM piece_category_suggestions pc
JOIN section_categories c ON c.id = pc.category_id
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
);
const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name`
);
return rows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary,
status: row.status,
revision: row.revision,
durationSeconds: row.duration_seconds,
formatId: row.format_id,
format: row.format,
categories: categoryStatement.all(row.id) as PieceListItem['categories'],
topics: topicStatement.all(row.id) as PieceListItem['topics'],
sourceCount: row.source_count,
mediaId: row.media_id
}));
}
type PieceRow = {
id: string;
title: string;
summary: string;
format_id: string | null;
status: PieceStatus;
duration_seconds: number;
intro: string;
research_text: string;
moderation_notes: string;
transition_text: string;
presentation_text: string;
revision: number;
created_at_utc: string;
updated_at_utc: string;
};
export function getPiece(id: string): PieceEditor | null {
const database = getDatabase();
const row = database
.prepare(
`SELECT id, title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, revision, created_at_utc, updated_at_utc
FROM pieces WHERE id = ?`
)
.get(id) as PieceRow | undefined;
if (!row) return null;
return {
id: row.id,
title: row.title,
summary: row.summary,
formatId: row.format_id,
status: row.status,
durationSeconds: row.duration_seconds,
intro: row.intro,
researchText: row.research_text,
moderationNotes: row.moderation_notes,
transitionText: row.transition_text,
presentationText: row.presentation_text,
revision: row.revision,
createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc,
topicIds: (
database.prepare('SELECT topic_id FROM piece_topics WHERE piece_id = ?').all(id) as {
topic_id: string;
}[]
).map((entry) => entry.topic_id),
categoryIds: (
database
.prepare('SELECT category_id FROM piece_category_suggestions WHERE piece_id = ?')
.all(id) as { category_id: string }[]
).map((entry) => entry.category_id),
sources: (
database
.prepare(
'SELECT label, url, timecode_seconds FROM piece_sources WHERE piece_id = ? ORDER BY position'
)
.all(id) as { label: string; url: string; timecode_seconds: number | null }[]
).map((source) => ({
label: source.label,
url: source.url,
timecodeSeconds: source.timecode_seconds
})),
media: (
database
.prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position`
)
.all(id) as {
id: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[]
).map((media) => ({
id: media.id,
originalName: media.original_name,
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text,
position: media.position
}))
};
}
function replaceRelations(pieceId: string, input: PieceInput) {
const database = getDatabase();
database.prepare('DELETE FROM piece_topics WHERE piece_id = ?').run(pieceId);
const addTopic = database.prepare('INSERT INTO piece_topics (piece_id, topic_id) VALUES (?, ?)');
for (const id of input.topicIds) addTopic.run(pieceId, id);
database.prepare('DELETE FROM piece_category_suggestions WHERE piece_id = ?').run(pieceId);
const addCategory = database.prepare(
'INSERT INTO piece_category_suggestions (piece_id, category_id) VALUES (?, ?)'
);
for (const id of input.categoryIds) addCategory.run(pieceId, id);
database.prepare('DELETE FROM piece_sources WHERE piece_id = ?').run(pieceId);
const addSource = database.prepare(
`INSERT INTO piece_sources (id, piece_id, label, url, timecode_seconds, position)
VALUES (?, ?, ?, ?, ?, ?)`
);
input.sources.forEach((source, position) =>
addSource.run(randomUUID(), pieceId, source.label, source.url, source.timecodeSeconds, position)
);
}
export function savePiece(id: string, expectedRevision: number, rawInput: PieceInput) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1)
throw new Error('INVALID_REVISION');
const input = validatePieceInput(rawInput);
const database = getDatabase();
const updatedAtUtc = new Date().toISOString();
const save = database.transaction(() => {
const result = database
.prepare(
`UPDATE pieces SET title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
updatedAtUtc,
id,
expectedRevision
);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
replaceRelations(id, input);
});
save();
return { revision: expectedRevision + 1, updatedAtUtc };
}
function templateDefaults(template: PieceTemplateId): Partial<PieceInput> & { title: string } {
if (template === 'talk') {
return {
title: 'Unbenanntes Gespräch',
formatId: 'format-talk',
categoryIds: ['cat-jc'],
moderationNotes: '## Leitfragen\n\n- '
};
}
if (template === 'find') {
return {
title: 'Neues Fundstück',
formatId: 'format-find',
categoryIds: ['cat-reaction'],
presentationText: '## Kernaussage\n\n'
};
}
if (template === 'interaction') {
return {
title: 'Neue Interaktion',
formatId: 'format-quiz',
categoryIds: ['cat-game'],
moderationNotes: '## Ablauf\n\n1. '
};
}
return { title: 'Neues Piece' };
}
export function createPiece(template: PieceTemplateId = 'blank'): string {
if (!pieceTemplates.some((entry) => entry.id === template)) throw new Error('INVALID_TEMPLATE');
const defaults = templateDefaults(template);
const id = randomUUID();
const now = new Date().toISOString();
const input = validatePieceInput({
title: defaults.title,
summary: '',
formatId: defaults.formatId ?? null,
status: 'preparing',
durationSeconds: 0,
intro: '',
researchText: '',
moderationNotes: defaults.moderationNotes ?? '',
transitionText: '',
presentationText: defaults.presentationText ?? '',
topicIds: [],
categoryIds: defaults.categoryIds ?? [],
sources: []
});
const database = getDatabase();
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
input.title,
input.summary,
input.formatId,
input.status,
input.durationSeconds,
input.intro,
input.researchText,
input.moderationNotes,
input.transitionText,
input.presentationText,
now,
now
);
replaceRelations(id, input);
})();
return id;
}
export function captureInbox(titleValue: string, urlValue: string): string {
const title = cleanText(titleValue, 200, 'title');
if (!title) throw new Error('INVALID_TITLE');
const url = cleanText(urlValue, 2_000, 'source');
if (url && !validUrl(url)) throw new Error('INVALID_SOURCES');
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title,
status: 'inbox',
sources: url ? [{ label: 'Schnellaufnahme', url, timecodeSeconds: null }] : []
});
return id;
}
export function setPieceArchived(id: string, expectedRevision: number, archived: boolean) {
const database = getDatabase();
const now = new Date().toISOString();
const result = archived
? database
.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(now, id, expectedRevision)
: database
.prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'`
)
.run(now, id, expectedRevision);
if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
}
}
export async function duplicatePiece(sourceId: string): Promise<string> {
const source = getPiece(sourceId);
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase();
const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position')
.all(sourceId) as {
id: string;
storage_name: string;
original_name: string;
mime_type: string;
byte_size: number;
alt_text: string;
position: number;
}[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = [];
try {
for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!;
const storageName = `${randomUUID()}.${extension}`;
await copyFile(
path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName)
);
copied.push({ source: media, storageName });
}
database.transaction(() => {
database
.prepare(
`INSERT INTO pieces (id, title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
`Kopie von ${source.title}`.slice(0, 200),
source.summary,
source.formatId,
source.durationSeconds,
source.intro,
source.researchText,
source.moderationNotes,
source.transitionText,
source.presentationText,
now,
now
);
replaceRelations(id, { ...source, status: 'preparing' });
const addMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const media of copied) {
addMedia.run(
randomUUID(),
id,
media.storageName,
media.source.original_name,
media.source.mime_type,
media.source.byte_size,
media.source.alt_text,
media.source.position,
now
);
}
})();
return id;
} catch (error) {
await Promise.all(
copied.map((media) => unlink(path.join(uploadDirectory, media.storageName)).catch(() => {}))
);
throw error;
}
}
export async function uploadPieceImage(
pieceId: string,
expectedRevision: number,
file: File,
altTextValue: string
) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(buffer);
const extension = detected ? ALLOWED_IMAGES.get(detected.mime) : undefined;
if (!detected || !extension) throw new Error('INVALID_IMAGE_TYPE');
const altText = cleanText(altTextValue, 300, 'alt');
const storageName = `${randomUUID()}.${extension}`;
const uploadDirectory = getServerPaths().uploads;
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase();
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?')
.get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
database
.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
pieceId,
storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime,
buffer.byteLength,
altText,
count.count,
new Date().toISOString()
);
})();
} catch (error) {
await unlink(filePath).catch(() => {});
throw error;
}
}
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase();
const media = database
.prepare('SELECT storage_name FROM piece_media WHERE id = ? AND piece_id = ?')
.get(mediaId, pieceId) as { storage_name: string } | undefined;
if (!media) throw new Error('NOT_FOUND');
const originalPath = path.join(getServerPaths().uploads, media.storage_name);
const quarantinePath = `${originalPath}.deleting-${randomUUID()}`;
await rename(originalPath, quarantinePath);
try {
database.transaction(() => {
const updated = database
.prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'`
)
.run(new Date().toISOString(), pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId);
})();
} catch (error) {
await rename(quarantinePath, originalPath).catch((restoreError) => {
console.error(
'Bilddatei konnte nach fehlgeschlagener Löschung nicht wiederhergestellt werden.',
restoreError
);
});
throw error;
}
try {
await unlink(quarantinePath);
} catch (error) {
console.error(
'Gelöschte Bilddatei konnte nicht aus dem Quarantänepfad entfernt werden.',
error
);
throw new Error('IMAGE_CLEANUP_FAILED', { cause: error });
}
}
export async function getMediaFile(id: string) {
const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?')
.get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size,
altText: row.alt_text
};
}
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase();
const duplicate = database
.prepare(
`SELECT 1 FROM ${kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics'}
WHERE lower(name) = lower(?)`
)
.get(name);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID();
if (kind === 'category') {
const color = String(colorValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
.get() as {
next: number;
};
database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next);
} else {
const table = kind === 'format' ? 'formats' : 'topics';
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
}
}
export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
if (
['format-talk', 'format-find', 'format-quiz', 'cat-jc', 'cat-reaction', 'cat-game'].includes(id)
) {
throw new Error('TAXONOMY_IN_USE');
}
const database = getDatabase();
const usage =
kind === 'format'
? (
database.prepare('SELECT count(*) AS count FROM pieces WHERE format_id = ?').get(id) as {
count: number;
}
).count
: kind === 'topic'
? (
database
.prepare('SELECT count(*) AS count FROM piece_topics WHERE topic_id = ?')
.get(id) as {
count: number;
}
).count
: (
database
.prepare(
`SELECT
(SELECT count(*) FROM piece_category_suggestions WHERE category_id = ?) +
(SELECT count(*) FROM show_sections WHERE category_id = ?) AS count`
)
.get(id, id) as { count: number }
).count;
if (usage > 0) throw new Error('TAXONOMY_IN_USE');
const result = database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
+1 -1
View File
@@ -52,7 +52,7 @@ describe('database setup', () => {
.get() as { migrations: number; pieces: number; shows: number; usages: number }; .get() as { migrations: number; pieces: number; shows: number; usages: number };
database.close(); database.close();
expect(counts).toEqual({ migrations: 2, pieces: 4, shows: 1, usages: 3 }); expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 });
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
}); });
+42
View File
@@ -0,0 +1,42 @@
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
export function renderMarkdown(markdown: string): string {
const rendered = marked.parse(markdown, {
async: false,
breaks: true,
gfm: true
}) as string;
return sanitizeHtml(rendered, {
allowedTags: [
'p',
'br',
'strong',
'em',
'del',
'blockquote',
'ul',
'ol',
'li',
'h2',
'h3',
'h4',
'code',
'pre',
'a',
'hr'
],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel']
},
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
enforceHtmlBoundary: true,
transformTags: {
a: (_tagName, attributes) => ({
tagName: 'a',
attribs: { ...attributes, target: '_blank', rel: 'noopener noreferrer' }
})
}
});
}
+1 -1
View File
@@ -85,7 +85,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status != 'archived' WHERE p.status IN ('preparing', 'ready')
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title` ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
) )
.all() as PieceRow[]; .all() as PieceRow[];
+22
View File
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['archived'] }) };
};
export const actions = {
restore: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Piece wurde in seinen vorherigen Bereich zurückgelegt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
}
} satisfies Actions;
+27 -5
View File
@@ -1,13 +1,35 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Archiv | Sendewerk</title></svelte:head> <svelte:head><title>Archiv | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <h1>Archiv</h1>
<p class="page-intro">Abgeschlossene Sendungen in ihrem damaligen Stand.</p> <p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
</header> </header>
<section class="quiet-panel planned-panel"> {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
<p>{piece.summary || 'Keine Kurzbeschreibung.'}</p>
</div>
<form method="POST" action="?/restore">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div> <div class="planned-icon" aria-hidden="true">□</div>
<h2>Noch keine Sendung archiviert</h2> <h2>Keine Pieces archiviert</h2>
<p>Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.</p> <p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section> </section>{/if}
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
};
export const actions = {
capture: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
redirect(303, `/fundus/${id}`);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
return fail(400, {
title: String(data.get('title') || ''),
url: String(data.get('url') || ''),
error:
code === 'INVALID_SOURCES'
? 'Bitte eine vollständige HTTP- oder HTTPS-Adresse eingeben.'
: 'Ein Titel ist erforderlich.'
});
}
}
} satisfies Actions;
+52 -5
View File
@@ -1,13 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Eingang | Sendewerk</title></svelte:head> <svelte:head><title>Eingang | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Eingang</h1> <h1>Eingang</h1>
<p class="page-intro">Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.</p> <p class="page-intro">Gedanken und Fundstücke schnell festhalten und später ausarbeiten.</p>
</header> </header>
<section class="quiet-panel planned-panel"> <section class="quiet-panel capture-panel">
<div class="planned-icon" aria-hidden="true">+</div> <div>
<h2>Keine neuen Inhalte</h2> <p class="eyebrow">Schnellaufnahme</p>
<p>Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.</p> <h2>Was soll nicht verloren gehen?</h2>
</div>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/capture" class="capture-form">
<label
>Titel oder Gedanke<input
name="title"
required
maxlength="200"
value={form?.title ?? ''}
placeholder="Kurze Idee festhalten"
/></label
>
<label
>Link <span>optional</span><input
name="url"
type="url"
maxlength="2000"
value={form?.url ?? ''}
placeholder="https://…"
/></label
>
<button class="primary-button" type="submit">Aufnehmen und öffnen</button>
</form>
</section> </section>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<a
class="inbox-item"
href={resolve('/fundus/[id]', { id: piece.id })}
><span
><strong>{piece.title}</strong><small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
></span
><span>Ausarbeiten →</span></a
>{/each}
</section>
{:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div> </div>
+80 -3
View File
@@ -1,5 +1,82 @@
import { getPieces } from '#lib/server/previews.js'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
createPiece,
duplicatePiece,
getTaxonomy,
listPieces,
pieceTemplates,
setPieceArchived,
type PieceTemplateId
} from '#lib/server/content.js';
export function load() { export const load: PageServerLoad = ({ locals, url }) => {
return { pieces: getPieces() }; requireUser(locals.user);
const filters = {
query: url.searchParams.get('q') ?? '',
formatId: url.searchParams.get('format') ?? '',
topicId: url.searchParams.get('topic') ?? '',
categoryId: url.searchParams.get('category') ?? '',
status: url.searchParams.get('status') ?? ''
};
return {
pieces: listPieces({
statuses:
filters.status === 'ready'
? ['ready']
: filters.status === 'preparing'
? ['preparing']
: ['preparing', 'ready'],
query: filters.query,
formatId: filters.formatId,
topicId: filters.topicId,
categoryId: filters.categoryId
}),
taxonomy: getTaxonomy(),
templates: pieceTemplates,
filters
};
};
function actionError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') return 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.';
if (code === 'NOT_FOUND') return 'Das Piece wurde nicht gefunden.';
return 'Die Aktion konnte nicht ausgeführt werden.';
} }
export const actions = {
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = createPiece(String(data.get('template') || 'blank') as PieceTemplateId);
} catch (cause) {
return fail(400, { intent: 'create', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
duplicate: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
let id: string;
try {
id = await duplicatePiece(String(data.get('pieceId') || ''));
} catch (cause) {
return fail(400, { intent: 'duplicate', error: actionError(cause) });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
return { intent: 'archive', success: 'Piece archiviert.' };
} catch (cause) {
return fail(409, { intent: 'archive', error: actionError(cause) });
}
}
} satisfies Actions;
+107 -55
View File
@@ -1,19 +1,9 @@
<script lang="ts"> <script lang="ts">
import PreviewNotice from '#lib/components/PreviewNotice.svelte'; import { resolve } from '$app/paths';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageData } from './$types'; import type { PageProps } 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);
})
);
let { data, form }: PageProps = $props();
const statusLabel = { const statusLabel = {
ready: 'Sendefertig', ready: 'Sendefertig',
preparing: 'In Vorbereitung', preparing: 'In Vorbereitung',
@@ -30,57 +20,125 @@
<h1>Fundus</h1> <h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p> <p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div> </div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}> <div class="heading-stat" aria-label={`${data.pieces.length} gefundene Inhalte`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span> <strong>{data.pieces.length}</strong><span>Inhalte</span>
</div> </div>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<PreviewNotice area="Fundus" /> <details class="template-picker">
<summary>Neues Piece anlegen</summary>
<section class="library-toolbar" aria-label="Fundus durchsuchen und filtern"> <div class="template-grid">
<label class="search-field"> {#each data.templates as template (template.id)}
<span aria-hidden="true">⌕</span> <form method="POST" action="?/create">
<span class="sr-only">Fundus durchsuchen</span> <input type="hidden" name="template" value={template.id} />
<input bind:value={query} type="search" placeholder="Titel, Thema oder Format suchen" /> <button type="submit"
</label> ><strong>{template.name}</strong><span>{template.description}</span></button
<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
> >
</form>
{/each}
</div> </div>
</section> </details>
{#if filteredPieces.length > 0} <form class="library-filters" method="GET" aria-label="Fundus durchsuchen und filtern">
<label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
name="q"
type="search"
value={data.filters.query}
placeholder="Titel, Quelle oder Thema suchen"
/></label
>
<label
><span>Status</span><select name="status"
><option value="">Alle</option><option
value="ready"
selected={data.filters.status === 'ready'}>Sendefertig</option
><option value="preparing" selected={data.filters.status === 'preparing'}>In Arbeit</option
></select
></label
>
<label
><span>Format</span><select name="format"
><option value="">Alle</option>{#each data.taxonomy.formats as item (item.id)}<option
value={item.id}
selected={data.filters.formatId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Thema</span><select name="topic"
><option value="">Alle</option>{#each data.taxonomy.topics as item (item.id)}<option
value={item.id}
selected={data.filters.topicId === item.id}>{item.name}</option
>{/each}</select
></label
>
<label
><span>Abschnitt</span><select name="category"
><option value="">Alle</option>{#each data.taxonomy.categories as item (item.id)}<option
value={item.id}
selected={data.filters.categoryId === item.id}>{item.name}</option
>{/each}</select
></label
>
<button class="secondary-button" type="submit">Anwenden</button>
<a class="text-link" href={resolve('/fundus')}>Zurücksetzen</a>
</form>
{#if data.pieces.length}
<div class="piece-grid"> <div class="piece-grid">
{#each filteredPieces as piece (piece.id)} {#each data.pieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}> <article
class="piece-card"
style={`--category: ${piece.categories[0]?.color ?? '#8b8f98'}`}
>
{#if piece.mediaId}<img
class="piece-thumbnail"
src={`/media/${piece.mediaId}`}
alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"
>S</span
>{/if}
<div class="piece-primary"> <div class="piece-primary">
<h2>{piece.title}</h2> <h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary}</p> <p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
</div> </div>
<div class="piece-classification"> <div class="piece-classification">
<span class="category-label"><i></i>{piece.category}</span> <span class="category-label"
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
>
<div class="piece-tags"> <div class="piece-tags">
<span>{piece.format}</span> <span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each} >{topic.name}</span
>{/each}
</div> </div>
</div> </div>
<div class="piece-state"> <div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label" <span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span >{statusLabel[piece.status]}</span
><span class="piece-facts"
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
></span
> >
<span class="piece-facts"> </div>
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span> <div class="piece-actions">
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span> <a class="text-link" href={resolve('/fundus/[id]', { id: piece.id })}>Bearbeiten</a>
</span> <form method="POST" action="?/duplicate">
<input type="hidden" name="pieceId" value={piece.id} /><button
class="text-button"
type="submit">Duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button>
</form>
</div> </div>
</article> </article>
{/each} {/each}
@@ -88,15 +146,9 @@
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true">⌕</span>
<h2>Kein passendes Stück</h2> <h2>Kein passendes Piece</h2>
<p>Suche oder Filter zurücksetzen, um wieder alle Beispieldaten zu sehen.</p> <p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<button <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
type="button"
onclick={() => {
query = '';
filter = 'all';
}}>Filter zurücksetzen</button
>
</div> </div>
{/if} {/if}
</div> </div>
+171
View File
@@ -0,0 +1,171 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import {
deletePieceImage,
duplicatePiece,
getPiece,
getTaxonomy,
savePiece,
setPieceArchived,
uploadPieceImage,
type EditablePieceStatus,
type PieceInput,
type PieceSourceInput
} from '#lib/server/content.js';
import { renderMarkdown } from '#lib/server/markdown.js';
function renderedFields(
piece: Pick<
PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
>
) {
return {
intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes),
transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText)
};
}
function text(data: FormData, name: string) {
return String(data.get(name) || '');
}
function pieceInput(data: FormData): PieceInput {
const labels = data.getAll('sourceLabel').map(String);
const urls = data.getAll('sourceUrl').map(String);
const timecodes = data.getAll('sourceTimecode').map(String);
const sources: PieceSourceInput[] = [];
for (let index = 0; index < Math.max(labels.length, urls.length, timecodes.length); index += 1) {
const label = labels[index]?.trim() ?? '';
const url = urls[index]?.trim() ?? '';
const timecode = timecodes[index]?.trim() ?? '';
if (!label && !url && !timecode) continue;
sources.push({
label,
url,
timecodeSeconds: timecode ? Number(timecode) : null
});
}
return {
title: text(data, 'title'),
summary: text(data, 'summary'),
formatId: text(data, 'formatId') || null,
status: text(data, 'status') as EditablePieceStatus,
durationSeconds: Number(data.get('durationSeconds')),
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'),
transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'),
topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String),
sources
};
}
function saveError(cause: unknown) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return {
status: 409,
error: 'Eine neuere Änderung liegt vor. Deine Eingaben wurden nicht überschrieben.',
conflict: true
};
if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' };
if (code === 'INVALID_DURATION')
return { status: 400, error: 'Die Dauer muss zwischen 0 und 86.400 Sekunden liegen.' };
if (code === 'INVALID_SOURCES')
return {
status: 400,
error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.'
};
if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return {
status: 500,
error: 'Speichern fehlgeschlagen. Deine Eingaben bleiben im Formular erhalten.'
};
}
export const load: PageServerLoad = ({ locals, params }) => {
requireUser(locals.user);
const piece = getPiece(params.id);
if (!piece) error(404, 'Piece nicht gefunden');
if (piece.status === 'archived') redirect(303, '/archiv');
return { piece, taxonomy: getTaxonomy(), rendered: renderedFields(piece) };
};
export const actions = {
save: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const input = pieceInput(data);
try {
const saved = savePiece(params.id, Number(data.get('revision')), input);
return { intent: 'save', saved: true, ...saved, rendered: renderedFields(input) };
} catch (cause) {
const problem = saveError(cause);
return fail(problem.status, { intent: 'save', ...problem });
}
},
upload: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
const image = data.get('image');
if (!(image instanceof File))
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
try {
await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText'));
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT')
return fail(409, {
intent: 'upload',
error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.'
});
if (code === 'INVALID_IMAGE_SIZE')
return fail(400, {
intent: 'upload',
error: 'Das Bild darf höchstens 5 MB groß und nicht leer sein.'
});
if (code === 'INVALID_IMAGE_TYPE')
return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' });
return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' });
}
redirect(303, `/fundus/${params.id}`);
},
deleteImage: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
await deletePieceImage(params.id, text(data, 'mediaId'), Number(data.get('revision')));
} catch (cause) {
return fail(409, { intent: 'deleteImage', error: saveError(cause).error });
}
redirect(303, `/fundus/${params.id}`);
},
duplicate: async ({ locals, params }) => {
requireUser(locals.user);
let id: string;
try {
id = await duplicatePiece(params.id);
} catch {
return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' });
}
redirect(303, `/fundus/${id}`);
},
archive: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceArchived(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'archive', error: saveError(cause).error });
}
redirect(303, '/archiv');
}
} satisfies Actions;
+363
View File
@@ -0,0 +1,363 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onDestroy, untrack } from 'svelte';
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let draft = $state(untrack(() => ({ ...data.piece })));
let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let rendered = $state(untrack(() => ({ ...data.rendered })));
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
let saveMessage = $state('Alle Änderungen gespeichert');
let editorForm: HTMLFormElement;
let saveButton: HTMLButtonElement;
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let saving = $state(false);
let actionsBlocked = $derived(
saveState === 'dirty' ||
saveState === 'saving' ||
saveState === 'error' ||
saveState === 'conflict'
);
let pendingHref: string | null = null;
let allowNavigation = false;
onDestroy(() => clearTimeout(timer));
beforeNavigate((navigation) => {
if (allowNavigation) return;
if (saveState === 'error' || saveState === 'conflict') {
if (!confirm('Ungespeicherte Änderungen verwerfen und Seite verlassen?')) navigation.cancel();
return;
}
if (saveState !== 'dirty' && saveState !== 'saving') return;
navigation.cancel();
if (navigation.willUnload) return;
pendingHref = navigation.to?.url.href ?? null;
if (!saving) submitSave();
});
function scheduleSave() {
generation += 1;
saveState = 'dirty';
saveMessage = 'Ungespeicherte Änderungen';
clearTimeout(timer);
timer = setTimeout(submitSave, 900);
}
function submitSave() {
if (saving) return;
editorForm.requestSubmit(saveButton);
}
function addSource() {
sources.push({ label: '', url: '', timecodeSeconds: null });
scheduleSave();
}
function removeSource(index: number) {
sources.splice(index, 1);
scheduleSave();
}
const enhanceSave: SubmitFunction = () => {
const submittedGeneration = generation;
saving = true;
saveState = 'saving';
saveMessage = 'Speichert …';
return async ({ result }) => {
saving = false;
if (result.type === 'success') {
const response = result.data as { revision: number; rendered: typeof rendered };
revision = response.revision;
rendered = response.rendered;
if (generation === submittedGeneration) {
saveState = 'saved';
saveMessage = 'Gespeichert';
if (pendingHref) {
const target = pendingHref;
pendingHref = null;
allowNavigation = true;
// The target comes from SvelteKit's own NavigationTarget, never from user input.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(new URL(target), { invalidateAll: true });
}
} else {
saveState = 'dirty';
saveMessage = 'Weitere Änderungen warten';
timer = setTimeout(submitSave, 250);
}
} else if (result.type === 'failure') {
const response = result.data as { error?: string; conflict?: boolean };
saveState = response.conflict ? 'conflict' : 'error';
saveMessage = response.error ?? 'Speichern fehlgeschlagen.';
} else {
saveState = 'error';
saveMessage = 'Speichern fehlgeschlagen.';
}
};
};
</script>
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
<div class="page-wrap editor-page">
<header class="editor-header">
<div>
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
<h1>{draft.title}</h1>
</div>
<div
class:problem={saveState === 'error' || saveState === 'conflict'}
class="save-indicator"
role="status"
aria-live="polite"
>
<i></i><span>{saveMessage}</span>
</div>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form
bind:this={editorForm}
use:enhance={enhanceSave}
method="POST"
action="?/save"
class="piece-editor"
oninput={scheduleSave}
onchange={scheduleSave}
>
<input type="hidden" name="revision" value={revision} />
<section class="editor-section editor-basics">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h2>Worum geht es?</h2>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
>
<label
>Status<select name="status" bind:value={draft.status}
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
><option value="ready">Sendefertig</option></select
></label
>
<label
>Format<select name="formatId" bind:value={draft.formatId}
><option value={null}>Ohne Format</option
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
>{item.name}</option
>{/each}</select
></label
>
<label
>Dauer in Sekunden<input
name="durationSeconds"
type="number"
min="0"
max="86400"
step="1"
bind:value={draft.durationSeconds}
/></label
>
<label class="wide-field"
>Kurzbeschreibung<textarea
name="summary"
rows="2"
maxlength="2000"
bind:value={draft.summary}></textarea></label
>
</div>
</section>
<section class="editor-section taxonomy-editor">
<div class="section-title">
<p class="eyebrow">Einordnung</p>
<h2>Themen und Abschnitte</h2>
</div>
<fieldset>
<legend>Themen</legend>
<div class="choice-grid">
{#each data.taxonomy.topics as item (item.id)}<label
><input
type="checkbox"
name="topicIds"
value={item.id}
bind:group={draft.topicIds}
/>{item.name}</label
>{/each}
</div>
</fieldset>
<fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend>
<div class="choice-grid">
{#each data.taxonomy.categories as item (item.id)}<label
><input
type="checkbox"
name="categoryIds"
value={item.id}
bind:group={draft.categoryIds}
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
>{/each}
</div>
</fieldset>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h2>Getrennte Arbeitsbereiche</h2>
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea
name="intro"
rows="7"
maxlength="50000"
bind:value={draft.intro}></textarea>
<MarkdownPreview html={rendered.intro} /></label
>
<label class="private-field"
><span>Private Recherche</span><textarea
name="researchText"
rows="7"
maxlength="50000"
bind:value={draft.researchText}></textarea>
<MarkdownPreview html={rendered.research} /></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderationNotes"
rows="7"
maxlength="50000"
bind:value={draft.moderationNotes}></textarea>
<MarkdownPreview html={rendered.moderation} /></label
>
<label
><span>Überleitung</span><textarea
name="transitionText"
rows="7"
maxlength="50000"
bind:value={draft.transitionText}></textarea>
<MarkdownPreview html={rendered.transition} /></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea
name="presentationText"
rows="7"
maxlength="50000"
bind:value={draft.presentationText}></textarea>
<MarkdownPreview html={rendered.presentation} /></label
>
</div>
</section>
<section class="editor-section source-editor">
<div class="section-title split-section-title">
<div>
<p class="eyebrow">Belege</p>
<h2>Quellen</h2>
</div>
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
>
</div>
{#if sources.length}<div class="source-list">
{#each sources as source, index (index)}<div class="source-row">
<label
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
><label
>URL<input
name="sourceUrl"
type="url"
maxlength="2000"
bind:value={source.url}
/></label
><label
>Zeitmarke in Sekunden<input
name="sourceTimecode"
type="number"
min="0"
max="86400"
bind:value={source.timecodeSeconds}
/></label
><button
class="text-button danger-text"
type="button"
onclick={() => removeSource(index)}>Entfernen</button
>
</div>{/each}
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
</section>
<div class="editor-savebar">
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
>Jetzt speichern</button
>
</div>
</form>
<section class="editor-section media-editor">
<div class="section-title">
<p class="eyebrow">Private Dateien</p>
<h2>Bilder</h2>
<small>JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar.</small>
</div>
{#if data.piece.media.length}<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} />
<figcaption>
<span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage">
<input type="hidden" name="revision" value={revision} /><input
type="hidden"
name="mediaId"
value={media.id}
/><button class="text-button danger-text" type="submit" disabled={actionsBlocked}
>Entfernen</button
>
</form>
</figcaption>
</figure>{/each}
</div>{/if}
<form method="POST" action="?/upload" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="revision" value={revision} /><label
>Bild<input
name="image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
required
/></label
><label
>Alternativtext<input
name="altText"
maxlength="300"
placeholder="Was ist auf dem Bild zu sehen?"
/></label
><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button
>
</form>
</section>
<section class="editor-actions">
<form method="POST" action="?/duplicate">
<button class="secondary-button" type="submit" disabled={actionsBlocked}
>Piece duplizieren</button
>
</form>
<form method="POST" action="?/archive">
<input type="hidden" name="revision" value={revision} /><button
class="secondary-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece archivieren</button
>
</form>
</section>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { getMediaFile } from '#lib/server/content.js';
export const GET: RequestHandler = async ({ locals, params }) => {
requireUser(locals.user);
const media = await getMediaFile(params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
+40 -2
View File
@@ -1,6 +1,11 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getTaxonomy } from '#lib/server/previews.js'; import {
createTaxonomy,
deleteTaxonomy,
getTaxonomy,
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
import { import {
inviteUser, inviteUser,
@@ -29,7 +34,12 @@ function actionError(cause: unknown): string {
if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.'; if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.';
if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.'; if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.';
if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.'; if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.';
if (code === 'NOT_FOUND') return 'Das Konto wurde nicht gefunden.'; if (code === 'NOT_FOUND') return 'Der Eintrag wurde nicht gefunden.';
if (code === 'TAXONOMY_EXISTS') return 'Dieser Name ist bereits vorhanden.';
if (code === 'TAXONOMY_IN_USE')
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
return 'Die Aktion konnte nicht ausgeführt werden.'; return 'Die Aktion konnte nicht ausgeführt werden.';
} }
@@ -93,5 +103,33 @@ export const actions = {
const data = await request.formData(); const data = await request.formData();
revokeInvitation(actor, String(data.get('invitationId') || '')); revokeInvitation(actor, String(data.get('invitationId') || ''));
return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' }; return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' };
},
createTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
createTaxonomy(kind, String(data.get('name') || ''), String(data.get('color') || ''));
return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
},
deleteTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
const kind = String(data.get('kind') || '') as TaxonomyKind;
if (!['category', 'format', 'topic'].includes(kind)) {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
}
try {
deleteTaxonomy(kind, String(data.get('taxonomyId') || ''));
return { intent: 'taxonomy', success: 'Eintrag entfernt.' };
} catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
}
} }
} satisfies Actions; } satisfies Actions;
+71 -6
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
}
</script> </script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head> <svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
@@ -114,25 +118,86 @@
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p> <p class="eyebrow">Abschnittskategorien</p>
<h2>Struktur im Ablauf</h2> <h2>Struktur im Ablauf</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.categories as category (category.id)}<span {#each data.taxonomy.categories as category (category.id)}<span
><i style={`--tax-color: ${category.color}`}></i>{category.name}</span ><i style={`--tax-color: ${category.color}`}></i>{category.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="category" /><input
type="hidden"
name="taxonomyId"
value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button>
</form></span
>{/each} >{/each}
</div> </div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="category" /><label
><span class="sr-only">Neue Abschnittskategorie</span><input
name="name"
required
maxlength="80"
placeholder="Neue Kategorie"
/></label
><label class="color-field"
><span class="sr-only">Farbe</span><input
name="color"
type="color"
value="#67DDE8"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section> </section>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Formate</p> <p class="eyebrow">Formate</p>
<h2>Form des Stücks</h2> <h2>Form des Stücks</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.formats as format (format.id)}<span>{format.name}</span>{/each} {#each data.taxonomy.formats as format (format.id)}<span
>{format.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="format" /><input
type="hidden"
name="taxonomyId"
value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button>
</form></span
>{/each}
</div> </div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="format" /><label
><span class="sr-only">Neues Format</span><input
name="name"
required
maxlength="80"
placeholder="Neues Format"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section> </section>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Themen</p> <p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2> <h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list"> <div class="taxonomy-list editable-taxonomy">
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each} {#each data.taxonomy.topics as topic (topic.id)}<span
>{topic.name}
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" /><input
type="hidden"
name="taxonomyId"
value={topic.id}
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button>
</form></span
>{/each}
</div> </div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label
><span class="sr-only">Neues Thema</span><input
name="name"
required
maxlength="80"
placeholder="Neues Thema"
/></label
><button class="text-button" type="submit">Hinzufügen</button>
</form>
</section> </section>
</div> </div>
</div> </div>
+1
View File
@@ -8,6 +8,7 @@ chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}" export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}" export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}" export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}"
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-6M}"
export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}" export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}"
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}" export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}"
+124 -22
View File
@@ -178,23 +178,16 @@ test('desktop fundus is a compact, filterable list with persistent themes', asyn
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0); expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130); expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox(); const searchBox = await page.locator('.search-field').boundingBox();
const filtersBox = await page.locator('.filter-tabs').boundingBox(); const statusBox = await page.getByLabel('Status').boundingBox();
expect(searchBox?.height).toBe(filtersBox?.height); expect(searchBox?.height).toBe(statusBox?.height);
await expect(page.getByRole('button', { name: 'In Arbeit' })).toHaveCSS('white-space', 'nowrap');
const notice = page.locator('.preview-notice');
await expect(page.getByText('Vorschau · Fundus', { exact: true })).toBeVisible();
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeHidden();
await notice.locator('summary').focus();
await expect(notice.locator('summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeVisible();
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.getByRole('button', { name: 'In Arbeit' }).click(); await page.getByLabel('Status').selectOption('preparing');
await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible(); await page.getByRole('button', { name: 'Anwenden' }).click();
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page.getByRole('link', { name: 'Filter zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4); await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('button', { name: 'Darstellung: System' }).click(); await page.getByRole('button', { name: 'Darstellung: System' }).click();
@@ -334,17 +327,16 @@ test('long titles and longer fundus lists reflow without overflow', async ({ pag
}); });
}); });
test('empty states and administration stay concise', async ({ page }) => { test('inbox, archive and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) { for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
for (const route of ['/eingang', '/archiv']) { await page.goto('/eingang');
await page.goto(route); await expect(page.locator('.capture-panel')).toBeVisible();
const emptyState = page.locator('.planned-panel'); await expect(page.locator('.inbox-item')).toHaveCount(1);
await expect(emptyState).toBeVisible(); await expectNoHorizontalOverflow(page);
const box = await emptyState.boundingBox(); await page.goto('/archiv');
expect(box?.height ?? 999).toBeLessThan(150); await expect(page.locator('.archive-list article')).toHaveCount(1);
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}
} }
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
@@ -378,3 +370,113 @@ test('live preview retains its existing readable controls', async ({ page }) =>
await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page,
browser
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.getByText('Neues Piece anlegen').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url();
await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown');
await page.getByRole('link', { name: '← Fundus', exact: true }).click();
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Sekunden').fill('615');
await page.getByLabel('Gaming').check();
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
await page
.getByLabel('Präsentationstext')
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
await page.getByRole('button', { name: 'Quelle hinzufügen' }).click();
const source = page.locator('.source-row').last();
await source.getByLabel('Name').fill('Originalbeitrag');
await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck');
await source.getByLabel('Zeitmarke in Sekunden').fill('42');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
await page.reload();
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
await expect(page.getByLabel('Private Recherche')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Ein Testpixel');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
const image = page.locator('.media-grid img');
await expect(image).toHaveCount(1);
await expect(image).toHaveAttribute('alt', 'Ein Testpixel');
const mediaUrl = await image.getAttribute('src');
const anonymous = await browser.newContext();
const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), {
maxRedirects: 0
});
expect(anonymousResponse.status()).toBe(303);
await anonymous.close();
const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl);
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', {
timeout: 10_000
});
await concurrent.reload();
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
await concurrent.close();
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible();
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
await expect(archived).toBeVisible();
await archived.getByRole('button', { name: 'Wiederherstellen' }).click();
await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel')).toHaveValue(
'Kopie von E2E Fundstück, extern aktualisiert'
);
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true
});
});