From 7590be66a5e543555dfd6ff8bf614fd96a4e709f Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Wed, 21 Oct 2026 21:05:57 +0200 Subject: [PATCH] Release 0.3.0 --- .env.example | 1 + CloudronManifest.json | 2 +- README.md | 28 +- docs/ACCEPTANCE.md | 25 + docs/ARCHITECTURE.md | 54 +- docs/DECISIONS.md | 37 + docs/STATUS.md | 31 +- migrations/0003_content_management.sql | 23 + package-lock.json | 304 +++++++- package.json | 8 +- scripts/seed.mjs | 58 +- src/app.css | 613 ++++++++++++++++- src/lib/components/MarkdownPreview.svelte | 7 + src/lib/server/content.spec.ts | 169 +++++ src/lib/server/content.ts | 803 ++++++++++++++++++++++ src/lib/server/database.spec.ts | 2 +- src/lib/server/markdown.ts | 42 ++ src/lib/server/previews.ts | 2 +- src/routes/archiv/+page.server.ts | 22 + src/routes/archiv/+page.svelte | 34 +- src/routes/eingang/+page.server.ts | 30 + src/routes/eingang/+page.svelte | 57 +- src/routes/fundus/+page.server.ts | 83 ++- src/routes/fundus/+page.svelte | 164 +++-- src/routes/fundus/[id]/+page.server.ts | 171 +++++ src/routes/fundus/[id]/+page.svelte | 363 ++++++++++ src/routes/media/[id]/+server.ts | 20 + src/routes/verwaltung/+page.server.ts | 42 +- src/routes/verwaltung/+page.svelte | 77 ++- start.sh | 1 + tests/ui.e2e.ts | 148 +++- 31 files changed, 3270 insertions(+), 151 deletions(-) create mode 100644 migrations/0003_content_management.sql create mode 100644 src/lib/components/MarkdownPreview.svelte create mode 100644 src/lib/server/content.spec.ts create mode 100644 src/lib/server/content.ts create mode 100644 src/lib/server/markdown.ts create mode 100644 src/routes/archiv/+page.server.ts create mode 100644 src/routes/eingang/+page.server.ts create mode 100644 src/routes/fundus/[id]/+page.server.ts create mode 100644 src/routes/fundus/[id]/+page.svelte create mode 100644 src/routes/media/[id]/+server.ts diff --git a/.env.example b/.env.example index 59353e8..592d14a 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ SENDEWERK_DB_PATH=.data/sendewerk.sqlite SENDEWERK_UPLOAD_DIR=.data/uploads 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. SENDEWERK_SETUP_TOKEN= diff --git a/CloudronManifest.json b/CloudronManifest.json index 6a2826e..cc6cf09 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.2.0", + "version": "0.3.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index b5d380e..dc8f54b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst -Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten, -Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen. -Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen. +Etappe 1 bis 3: technisches Grundgerüst, Anmeldung und Rollen sowie einen +persistenten Fundus mit Eingang, Editor, Taxonomie, privaten Bildern und +Konfliktschutz. Sendungsbau und Live-Steuerung folgen in späteren Etappen. ## 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_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis | | `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_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit | | `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 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 | Befehl | Zweck | @@ -142,6 +158,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert. Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und -`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet -MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert -den tatsächlich installierten Baum. +`better-sqlite3`, `@node-rs/argon2`, Marked, sanitize-html und file-type sind +MIT-lizenziert; Nodemailer verwendet MIT-0, TypeScript und Playwright stehen +unter Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a6d2035..84576d6 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -66,3 +66,28 @@ - Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck und Migration vor Start 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 912c625..7c85bc9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,18 +36,18 @@ spätere API dieselben Regeln verwenden. Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt. -| Bereich | Geplante Entitäten und Beziehungen | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens 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 | -| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten | -| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | -| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | -| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | -| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | -| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | -| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | -| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | +| Bereich | Geplante Entitäten und Beziehungen | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 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 | +| 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 | +| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | +| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | +| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | +| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | +| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | +| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | ### Historien- und Kopierregel @@ -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 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 Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. @@ -108,7 +130,7 @@ Fachabläufe werden jedoch nicht vorgetäuscht. ## Sicherheitsgrenzen ab den Folgeetappen Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder -betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt. -Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen -interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln -sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen. +betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor +Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Spätere +Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und +kontrollierte Redirects. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2a85b99..fcdb416 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. Quelle: + +## 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: + +## 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: + +- +- + +## 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: diff --git a/docs/STATUS.md b/docs/STATUS.md index e8b1ffc..5db9253 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,9 @@ ## Aktueller Umfang -Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell, -Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde -nicht begonnen. +Etappe 1 bis Etappe 3 sind abgeschlossen. Architektur, Gesamtdatenmodell, +Anmeldung, Rollen, persistente Inhaltsbearbeitung und lokaler Betriebsweg sind +dokumentiert. Etappe 4 wurde nicht begonnen. ## 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 umgebrochen. -## Nicht Bestandteil dieser Etappe +## Noch nicht umgesetzt -Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung, -Präsentationsfenster, Archivabschluss, Freigaben, RSS und API. -Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden -werden. +Sendungsplanung, echte Live-Steuerung, Präsentationsfenster, Archivabschluss, +Freigaben, RSS und API. Die bestehenden Vorschauen dürfen nicht als +Implementierung dieser späteren Funktionen verstanden werden. ## Umgesetzter Stand Etappe 2 @@ -53,6 +52,22 @@ werden. - lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand - 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 Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten diff --git a/migrations/0003_content_management.sql b/migrations/0003_content_management.sql new file mode 100644 index 0000000..a0ec849 --- /dev/null +++ b/migrations/0003_content_management.sql @@ -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); diff --git a/package-lock.json b/package-lock.json index 39e149d..debf294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,19 @@ { "name": "sendewerk", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@node-rs/argon2": "^2.2.2", "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": { "@eslint/js": "^10.0.1", @@ -20,6 +23,7 @@ "@sveltejs/vite-plugin-svelte": "^7.2.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.18.13", + "@types/sanitize-html": "^2.16.2", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.19.0", @@ -37,6 +41,16 @@ "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": { "version": "2.2.0", "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" } }, + "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": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1010,6 +1047,16 @@ "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": { "version": "8.72.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", @@ -1475,11 +1522,16 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1527,6 +1579,85 @@ "dev": true, "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": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1538,7 +1669,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1859,6 +1989,24 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1956,6 +2104,48 @@ "dev": true, "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1999,6 +2189,15 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2047,6 +2246,15 @@ "dev": true, "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2365,6 +2573,18 @@ "@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": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2405,14 +2625,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, "funding": [ { "type": "github", @@ -2516,6 +2734,12 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2540,7 +2764,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2589,7 +2812,6 @@ "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2853,6 +3075,23 @@ "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": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2908,7 +3147,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2921,6 +3159,22 @@ "dev": true, "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": { "version": "5.57.2", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", @@ -3099,6 +3353,24 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -3173,6 +3445,18 @@ "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": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 5712814..b5b790a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "engines": { "node": ">=22.17 <25" @@ -26,7 +26,10 @@ "dependencies": { "@node-rs/argon2": "^2.2.2", "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": { "@eslint/js": "^10.0.1", @@ -36,6 +39,7 @@ "@sveltejs/vite-plugin-svelte": "^7.2.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.18.13", + "@types/sanitize-html": "^2.16.2", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.19.0", diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 3ac2eaf..3a9459c 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -124,9 +124,49 @@ export function seed(databasePath = getPaths().database) { 'Pixel als bewusste Entscheidung.', 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-%'); const addTopic = database.prepare( @@ -137,7 +177,9 @@ export function seed(databasePath = getPaths().database) { ['piece-effects', 'topic-film'], ['piece-quiz', '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); @@ -149,7 +191,9 @@ export function seed(databasePath = getPaths().database) { ['piece-console', 'cat-jc'], ['piece-effects', 'cat-reaction'], ['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); @@ -173,7 +217,15 @@ export function seed(databasePath = getPaths().database) { 95, 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 + ] ] ); diff --git a/src/app.css b/src/app.css index ae4b52f..1a212dd 100644 --- a/src/app.css +++ b/src/app.css @@ -79,7 +79,8 @@ body { button, input, -select { +select, +textarea { font: inherit; } @@ -1291,7 +1292,9 @@ a { background: transparent; } -.secondary-button:disabled { +.primary-button:disabled, +.secondary-button:disabled, +.text-button:disabled { cursor: not-allowed; opacity: 0.55; } @@ -1464,6 +1467,560 @@ a { 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) { .page-wrap { padding-inline: 2rem; @@ -1575,6 +2132,44 @@ a { .page-heading h1 { 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, .live-header { align-items: flex-start; @@ -1613,6 +2208,13 @@ a { border-radius: var(--radius-medium); background: var(--panel); } + .piece-thumbnail { + width: 100%; + height: 8rem; + } + .piece-thumbnail-placeholder { + display: none; + } .piece-card:last-child { border-bottom: 1px solid var(--line); } @@ -1641,6 +2243,13 @@ a { .piece-facts { justify-content: flex-start; } + .piece-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-start; + gap: 0.7rem; + } .show-summary { width: 100%; flex-wrap: wrap; diff --git a/src/lib/components/MarkdownPreview.svelte b/src/lib/components/MarkdownPreview.svelte new file mode 100644 index 0000000..0e6c256 --- /dev/null +++ b/src/lib/components/MarkdownPreview.svelte @@ -0,0 +1,7 @@ + + + + +
{@html html}
diff --git a/src/lib/server/content.spec.ts b/src/lib/server/content.spec.ts new file mode 100644 index 0000000..a4b1279 --- /dev/null +++ b/src/lib/server/content.spec.ts @@ -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 { + 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** [Link](javascript:alert(1))' + ); + expect(html).toContain('sicher'); + expect(html).not.toContain(' { + 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([''], '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); + }); +}); diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts new file mode 100644 index 0000000..928d0db --- /dev/null +++ b/src/lib/server/content.ts @@ -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; +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 & { + 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 & { 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 { + 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'); +} diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index aeca841..d69edd0 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -52,7 +52,7 @@ describe('database setup', () => { .get() as { migrations: number; pieces: number; shows: number; usages: number }; 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')); }); diff --git a/src/lib/server/markdown.ts b/src/lib/server/markdown.ts new file mode 100644 index 0000000..e696108 --- /dev/null +++ b/src/lib/server/markdown.ts @@ -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' } + }) + } + }); +} diff --git a/src/lib/server/previews.ts b/src/lib/server/previews.ts index f8d97e4..6d42a92 100644 --- a/src/lib/server/previews.ts +++ b/src/lib/server/previews.ts @@ -85,7 +85,7 @@ export function getPieces(): PreviewPiece[] { (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count FROM pieces p LEFT JOIN formats f ON f.id = p.format_id - WHERE p.status != 'archived' + WHERE p.status IN ('preparing', 'ready') ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title` ) .all() as PieceRow[]; diff --git a/src/routes/archiv/+page.server.ts b/src/routes/archiv/+page.server.ts new file mode 100644 index 0000000..9d00a8f --- /dev/null +++ b/src/routes/archiv/+page.server.ts @@ -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; diff --git a/src/routes/archiv/+page.svelte b/src/routes/archiv/+page.svelte index 37d74c7..483af28 100644 --- a/src/routes/archiv/+page.svelte +++ b/src/routes/archiv/+page.svelte @@ -1,13 +1,35 @@ + + Archiv | Sendewerk

Archiv

-

Abgeschlossene Sendungen in ihrem damaligen Stand.

+

Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.

-
- -

Noch keine Sendung archiviert

-

Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.

-
+ {#if form?.error}{/if} + {#if form?.success}
{form.success}
{/if} + {#if data.pieces.length}
+ {#each data.pieces as piece (piece.id)}
+
+

{piece.title}

+

{piece.summary || 'Keine Kurzbeschreibung.'}

+
+
+ +
+
{/each} +
+ {:else}
+ +

Keine Pieces archiviert

+

Archivierte Inhalte lassen sich hier später wiederherstellen.

+
{/if}
diff --git a/src/routes/eingang/+page.server.ts b/src/routes/eingang/+page.server.ts new file mode 100644 index 0000000..9ef83e8 --- /dev/null +++ b/src/routes/eingang/+page.server.ts @@ -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; diff --git a/src/routes/eingang/+page.svelte b/src/routes/eingang/+page.svelte index b02151d..3c2e523 100644 --- a/src/routes/eingang/+page.svelte +++ b/src/routes/eingang/+page.svelte @@ -1,13 +1,60 @@ + + Eingang | Sendewerk

Eingang

-

Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.

+

Gedanken und Fundstücke schnell festhalten und später ausarbeiten.

-
- -

Keine neuen Inhalte

-

Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.

+
+
+

Schnellaufnahme

+

Was soll nicht verloren gehen?

+
+ {#if form?.error}{/if} +
+ + + +
+ {#if data.pieces.length} +
+

{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}

+ {#each data.pieces as piece (piece.id)}{piece.title}{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}Ausarbeiten →{/each} +
+ {:else}
+ +

Eingang ist leer

+

Neue Gedanken und Links können oben direkt aufgenommen werden.

+
{/if}
diff --git a/src/routes/fundus/+page.server.ts b/src/routes/fundus/+page.server.ts index 64ecc3e..1c90748 100644 --- a/src/routes/fundus/+page.server.ts +++ b/src/routes/fundus/+page.server.ts @@ -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() { - return { pieces: getPieces() }; +export const load: PageServerLoad = ({ locals, url }) => { + 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; diff --git a/src/routes/fundus/+page.svelte b/src/routes/fundus/+page.svelte index 0ae4357..2a01571 100644 --- a/src/routes/fundus/+page.svelte +++ b/src/routes/fundus/+page.svelte @@ -1,19 +1,9 @@ + +{draft.title} | Fundus | Sendewerk + +
+
+
+ ← Fundus +

{draft.title}

+
+
+ {saveMessage} +
+
+ {#if form?.error}{/if} + +
+ +
+
+

Grundlagen

+

Worum geht es?

+
+
+ + + + + +
+
+ +
+
+

Einordnung

+

Themen und Abschnitte

+
+
+ Themen +
+ {#each data.taxonomy.topics as item (item.id)}{/each} +
+
+
+ Vorgeschlagene Abschnittskategorien +
+ {#each data.taxonomy.categories as item (item.id)}{/each} +
+
+
+ +
+
+

Redaktion

+

Getrennte Arbeitsbereiche

+ Markdown wird unterstützt und vor der Anzeige bereinigt. +
+
+ + + + + +
+
+ +
+
+
+

Belege

+

Quellen

+
+ +
+ {#if sources.length}
+ {#each sources as source, index (index)}
+ +
{/each} +
{:else}

Noch keine Quellen hinterlegt.

{/if} +
+ +
+ {saveMessage} +
+
+ +
+
+

Private Dateien

+

Bilder

+ JPEG, PNG, WebP oder GIF bis 5 MB; nur angemeldet abrufbar. +
+ {#if data.piece.media.length}
+ {#each data.piece.media as media (media.id)}
+ {media.altText} +
+ {media.altText || media.originalName} +
+ +
+
+
{/each} +
{/if} +
+ +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/src/routes/media/[id]/+server.ts b/src/routes/media/[id]/+server.ts new file mode 100644 index 0000000..db09fea --- /dev/null +++ b/src/routes/media/[id]/+server.ts @@ -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' + } + }); +}; diff --git a/src/routes/verwaltung/+page.server.ts b/src/routes/verwaltung/+page.server.ts index 1aa6889..b4e3bfd 100644 --- a/src/routes/verwaltung/+page.server.ts +++ b/src/routes/verwaltung/+page.server.ts @@ -1,6 +1,11 @@ import { fail } from '@sveltejs/kit'; 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 { 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 === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.'; 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.'; } @@ -93,5 +103,33 @@ export const actions = { const data = await request.formData(); revokeInvitation(actor, String(data.get('invitationId') || '')); 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; diff --git a/src/routes/verwaltung/+page.svelte b/src/routes/verwaltung/+page.svelte index 3ebcac9..69bd7b6 100644 --- a/src/routes/verwaltung/+page.svelte +++ b/src/routes/verwaltung/+page.svelte @@ -1,6 +1,10 @@ Verwaltung | Sendewerk @@ -114,25 +118,86 @@

Abschnittskategorien

Struktur im Ablauf

-
+
{#each data.taxonomy.categories as category (category.id)}{category.name}{category.name} +
+ +
{/each}
+
+ +

Formate

Form des Stücks

-
- {#each data.taxonomy.formats as format (format.id)}{format.name}{/each} +
+ {#each data.taxonomy.formats as format (format.id)}{format.name} +
+ +
{/each}
+
+ +

Themen

Inhaltliche Zuordnung

-
- {#each data.taxonomy.topics as topic (topic.id)}{topic.name}{/each} +
+ {#each data.taxonomy.topics as topic (topic.id)}{topic.name} +
+ +
{/each}
+
+ +
diff --git a/start.sh b/start.sh index 71e7187..5e7733e 100755 --- a/start.sh +++ b/start.sh @@ -8,6 +8,7 @@ chown -R cloudron:cloudron /app/data export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}" export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}" export 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:-}}" if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}" diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index 616bf0a..da1997a 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -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?.height ?? 999).toBeLessThan(130); const searchBox = await page.locator('.search-field').boundingBox(); - const filtersBox = await page.locator('.filter-tabs').boundingBox(); - expect(searchBox?.height).toBe(filtersBox?.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(); + const statusBox = await page.getByLabel('Status').boundingBox(); + expect(searchBox?.height).toBe(statusBox?.height); 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 page.getByRole('button', { name: 'In Arbeit' }).click(); - await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible(); - await page.getByRole('button', { name: 'Filter zurücksetzen' }).click(); + await page.getByLabel('Status').selectOption('preparing'); + await page.getByRole('button', { name: 'Anwenden' }).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 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]) { await page.setViewportSize({ width, height: 900 }); - for (const route of ['/eingang', '/archiv']) { - await page.goto(route); - const emptyState = page.locator('.planned-panel'); - await expect(emptyState).toBeVisible(); - const box = await emptyState.boundingBox(); - expect(box?.height ?? 999).toBeLessThan(150); - await expectNoHorizontalOverflow(page); - } + await page.goto('/eingang'); + await expect(page.locator('.capture-panel')).toBeVisible(); + await expect(page.locator('.inbox-item')).toHaveCount(1); + await expectNoHorizontalOverflow(page); + await page.goto('/archiv'); + await expect(page.locator('.archive-list article')).toHaveCount(1); + await expectNoHorizontalOverflow(page); } 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 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 **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 + }); +});