diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f891da5 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-party notices + +## Font Awesome Pro Plus 7.0.0 + +The icon metadata in `src/lib/icons/classic-regular.ts` and the SVG sprite in +`static/icons/classic-regular.svg` are derived from Font Awesome Pro Plus 7.0.0 +Classic Regular. Font Awesome Pro is commercial software and remains subject to +the Font Awesome Pro commercial license: +https://fontawesome.com/license + +The Sendewerk MIT license does not apply to that icon data. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d88c020..83c9dc4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -370,3 +370,45 @@ - `bash -n start.sh` und `git diff --check`: erfolgreich - reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt; Anbieterrequests wurden in den Browsertests lokal abgefangen + +## Editor-Erlebnis 0.5.10 + +| Kriterium | Nachweis | Stand | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- | +| Titel direkt und per Tastatur bearbeitbar | Chromium prüft Vorlage, Inline-Titel, Autosave, Schließen und erneutes Öffnen | erfüllt am 23.10.2026 | +| Gemeinsame Themen- und Werkzeugauswahl | Browser prüft Auswahl, Speicherung und deaktivierte Werkzeugzuordnung; Svelte-Check ist warnfrei | erfüllt am 23.10.2026 | +| Themenicons lokal und persistent | Migration und Servicetests prüfen Iconwert und Snapshot; Verwaltung nutzt lokalen Iconpicker | erfüllt am 23.10.2026 | +| Präsentationsbild ausdrücklich gewählt | Browser lädt Bild hoch, aktualisiert Usage und sieht nur dieses Bild in anonymer Ausgabe | erfüllt am 23.10.2026 | +| Medien- und Usage-Snapshots bleiben stabil | Servicetest entfernt Originalmedium und erhält bestehende Usage-Ausgabe | erfüllt am 23.10.2026 | +| Piece-Löschen ist wiederherstellbar | Servicetest prüft Revision, Soft Delete, Listenausblendung, Medienzugriff und Wiederherstellung | erfüllt am 23.10.2026 | +| Live-Verwendungen sicher entfernbar | Servicetests erlauben nur offene, nie gelaufene, nicht aktuelle oder präsentierte Verwendungen | erfüllt am 23.10.2026 | +| Leere OBS-Ausgabe ist transparent | Chromium prüft leeren Renderer vor und nach Präsentation sowie versteckten Verbindungsstatus | erfüllt am 23.10.2026 | +| Responsive Bedienung | vollständige Browsermatrix prüft 390 bis 1440 Pixel, Light/Dark und horizontalen Überlauf | erfüllt am 23.10.2026 | +| Reales OBS, reale Anbieter und echtes iOS | In der lokalen Prüfumgebung nicht verfügbar | offen | +| Werkzeugkatalog entspricht Themenverwaltung | Kompakte Iconzeilen und gemeinsamer Anlegen-/Bearbeitendialog wurden auf Desktop/Mobil verglichen | erfüllt am 23.10.2026 | +| Vollständige Classic-Regular-Auswahl | Generierung liefert 3.671 Icons; seltenes Icon wurde gesucht, gespeichert und im Piece angezeigt | erfüllt am 23.10.2026 | +| Formatwahl nutzt gemeinsamen Picker | Browser ordnet Quiz im Dialog zu und erhält Format sowie Deaktivierungszustand nach Reload | erfüllt am 23.10.2026 | + +## Ausgeführte Prüfungen 0.5.10 + +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 40 Tests in 7 Dateien erfolgreich +- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich +- Browsermatrix bei 390, 768, 960, 1280 und 1440 Pixeln einschließlich + Light/Dark, mobiler Navigation, Dialogen, Planung, Live-Steuerung, anonymer + Ausgabe, Medienauswahl und privater Datenfilterung erfolgreich; Screenshots + wurden durch die Suite erzeugt +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- lokale Entwicklungsdatenbank vorab nach + `.data/backups/0.5.10-pre-migration/sendewerk.sqlite` gesichert; + `npm run db:migrate` hat `0010_editor_experience.sql` erfolgreich angewendet +- vor der Werkzeugmigration zusätzlich nach + `.data/backups/0.5.10-tool-icons-pre-migration/sendewerk.sqlite` gesichert; + `npm run db:migrate` hat `0011_tool_icons.sql` erfolgreich angewendet +- Werkzeugkatalog, Anlegen-/Bearbeitendialog, geöffnete Iconauswahl und + Piece-Werkzeugauswahl bei 1280 × 720 und 390 × 844 Pixeln visuell direkt mit + der Themenverwaltung verglichen; keine horizontale Überlauffläche festgestellt +- `bash -n start.sh`: erfolgreich +- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem + iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf5fa2a..2c37d19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,18 +37,18 @@ Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. -| 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, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots | -| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz | -| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer 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` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft | +| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots | +| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen | +| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer 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 @@ -106,7 +106,9 @@ unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt. Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle -bestehenden Zuordnungen erhält. +bestehenden Zuordnungen erhält. Ein serverseitig validiertes `icon_name` gehört +zum Werkzeugkatalog und wird im Originaleditor angezeigt, ohne bestehende +Usage-Snapshots nachträglich zu verändern. Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt @@ -136,6 +138,15 @@ 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. +Ein Piece kann genau eines seiner aktiven Medien ausdrücklich als +Präsentationsbild auswählen. Die Auswahl liegt in `piece_presentation_media`; +das Entfernen setzt am Medium einen Zeitstempel statt die Datei zu löschen. +Damit bleiben bereits erzeugte `usage_media_snapshots` auflösbar, während neue +Einplanungen und der Originaleditor nur aktive Medien berücksichtigen. Das +Soft Delete eines Pieces setzt ebenfalls nur `deleted_at_utc`: normale Listen +und neue Einplanungen schließen es aus, bestehende Verwendungsreferenzen und +Snapshots bleiben unverändert erhalten. + ## Sendungsbau Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine @@ -148,8 +159,9 @@ Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, Überleitung und der vollständige Präsentationszustand aus dem sendefertigen Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke, Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete -Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die -Themennamen. Eigene Notizen liegen ausschließlich an +Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die +Themennamen samt Icon sowie das ausdrücklich ausgewählte Präsentationsmedium. +Eigene Notizen liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte @@ -271,6 +283,12 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht ohne interne Anwendungsnavigation. +Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter +`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren +Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge +gleichzeitig. Allgemeine Seiten wie Login oder Navigation laden den Katalog +nicht in ihren Client-Chunk. + Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung, Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c4470e0..17687aa 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -334,3 +334,29 @@ Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene Piece-Zeiterfassung doppelt zu zählen. + +## D-023: Ausdrückliche Medienauswahl und wiederherstellbares Entfernen + +Ein privater Piece-Upload wird nicht allein durch seine Existenz öffentlich. +Das Original verweist ausdrücklich auf höchstens ein aktives +Präsentationsmedium. Beim Einplanen oder bewussten Aktualisieren wird diese +Auswahl mit Medium-ID und Alternativtext an die Verwendung kopiert. Die +öffentliche Route akzeptiert nur das Medium des aktuell präsentierten +Usage-Snapshots; Originalname, interner Speicherpfad und andere Uploads bleiben +außerhalb der Projektion. + +Medien und Pieces werden nicht physisch gelöscht, solange bestehende +Sendungsfassungen auf ihre unveränderlichen Snapshots angewiesen sein können. +Zeitstempel blenden sie aus aktuellen Arbeitslisten und neuen Einplanungen aus, +erhalten aber Referenzen und ermöglichen die Wiederherstellung von Pieces. Eine +Verwendung oder ein Abschnitt darf dagegen nur entfernt werden, solange keine +betroffene Verwendung jemals live gelaufen ist und weder aktuell noch +präsentiert wird. + +Der lokale Iconkatalog enthält die vollständige Classic-Regular-Familie mit +3.671 Icons. Ein generierter Metadatenkatalog ermöglicht Suche und serverseitige +Validierung, während ein statischer SVG-Sprite über den gemeinsamen +`currentColor`-Renderer dargestellt wird. So lädt die Navigation nicht sämtliche +Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf +noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und +Lizenz stehen in `THIRD_PARTY_NOTICES.md`. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..eeb187e --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,55 @@ +# Release Notes + +## 0.5.10 - Editor-Erlebnis + +### Redaktion und Verwaltung + +- Titel von Fundus- und Sendungsfassungen lassen sich direkt und per Tastatur + bearbeiten. +- Themen und Werkzeuge verwenden gemeinsame kompakte Auswahlflächen. +- Der Werkzeugkatalog entspricht der Themenverwaltung: kompakte Glasfläche, + Icon, Name, Status und Bearbeitungsaktion statt dauerhaft offener Formulare. +- Anlegen und Bearbeiten eines Werkzeugs verwenden denselben responsiven Dialog + für Name, Start-URL, Beschreibung, Icon, passende Formate und Aktivstatus. +- Die Formatzuordnung nutzt dieselbe durchsuchbare Mehrfachauswahl wie andere + Zuordnungen. Deaktivierte Werkzeuge und bestehende Piece-Zuordnungen bleiben + erhalten. +- Alle 3.671 Icons aus Font Awesome Pro Plus 7.0.0 Classic Regular stehen lokal + und durchsuchbar zur Verfügung. Ein lokaler SVG-Sprite hält den allgemeinen + Anwendungs-Chunk klein; es gibt keine externe Laufzeitabhängigkeit. + +### Pieces und Präsentation + +- Präsentationsbilder werden ausdrücklich aus aktiven Piece-Medien ausgewählt + und unveränderlich an Sendungsfassungen kopiert. +- Entfernte Medien bleiben für bestehende Sendungsfassungen verfügbar, werden + aber nicht mehr für neue Einplanungen angeboten. +- Pieces können wiederherstellbar gelöscht werden. Offene, nie live gelaufene + Verwendungen und Abschnitte lassen sich unter geschützten Bedingungen + entfernen. +- Fundus, Eingang, Archiv, Vorlagen und Sendungsansichten sind kompakter und + bleiben per Klick, Touch und Tastatur bedienbar. + +### Live-Ausgabe + +- Eine leere heinzlive-Ausgabe ist vollständig transparent und zeigt weder + Renderer noch Verbindungsstatus. +- Eine aktive Präsentation lässt sich unabhängig vom aktuell laufenden Piece + leeren. + +### Datenbank und Kompatibilität + +- `0010_editor_experience.sql` ergänzt Themenicons, Medien-Snapshots und Soft + Delete. +- `0011_tool_icons.sql` ergänzt persistente Werkzeugicons mit einem sicheren + Standardwert. Werkzeug-IDs, Formatbeziehungen, Piece-Zuordnungen und + Deaktivierungszustände bleiben erhalten. + +### Prüfung + +- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe erfolgreich. +- Werkzeugverwaltung, Iconauswahl und Piece-Werkzeugauswahl wurden zusätzlich + bei 1280 × 720 und 390 × 844 Pixeln visuell mit der Themenverwaltung + verglichen. +- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät + standen nicht zur Verfügung. diff --git a/docs/STATUS.md b/docs/STATUS.md index 9fc0960..a4cd118 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,6 +7,34 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Etappe 6 wurde nicht begonnen. +## Editor-Erlebnis 0.5.10 + +- direkte, tastaturbedienbare Titelbearbeitung in Fundus- und Sendungsfassungen + sowie gemeinsame kompakte Auswahlflächen für Themen und Werkzeuge +- vollständiger lokaler Classic-Regular-Katalog mit 3.671 Icons für Navigation, + Aktionen sowie frei wählbare Themen- und Werkzeugicons; durchsuchbare + Metadaten und SVG-Sprite vermeiden externe Laufzeitabhängigkeiten +- kompakter Werkzeugkatalog nach dem Muster der Themenverwaltung; Anlegen und + Bearbeiten teilen einen responsiven Dialog mit gestalteter Icon- und + Formatauswahl, bestehende Zuordnungen und Deaktivierung bleiben erhalten +- Präsentationsbilder werden bewusst aus aktiven Piece-Medien ausgewählt und als + unveränderlicher Usage-Snapshot eingeplant; private Bilder werden dadurch + weder implizit noch nach späterer Originaländerung öffentlich +- entfernte Medien bleiben für bestehende Sendungsfassungen erhalten, während + neue Einplanungen nur aktive Medien übernehmen +- wiederherstellbares Soft Delete für Pieces sowie geschütztes Entfernen offener, + nie live gelaufener Verwendungen und Abschnitte +- kompaktere Fundus-, Eingangs-, Archiv-, Vorlagen- und Sendungsansichten mit + mobilen, touch- und tastaturbedienbaren Alternativen +- transparente leere heinzlive-Ausgabe ohne sichtbaren Renderer oder + Verbindungsstatus; die Steuerung kann eine aktive Präsentation unabhängig vom + aktuellen Piece leeren + +Etappe 6 bleibt unverändert unbegonnen. Die lokale Prüfmatrix umfasst 40 +Verhaltenstests und 16 Chromium-Abläufe. Reales OBS, echte +YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät bleiben mangels +verfügbarer Umgebung offen. + ## Live-Werkbank 0.5.9 - globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete diff --git a/migrations/0010_editor_experience.sql b/migrations/0010_editor_experience.sql new file mode 100644 index 0000000..b4ea374 --- /dev/null +++ b/migrations/0010_editor_experience.sql @@ -0,0 +1,49 @@ +ALTER TABLE topics ADD COLUMN icon_name TEXT NOT NULL DEFAULT ''; + +ALTER TABLE pieces ADD COLUMN deleted_at_utc TEXT; + +ALTER TABLE piece_media ADD COLUMN removed_at_utc TEXT; + +CREATE TABLE piece_presentation_media ( + piece_id TEXT PRIMARY KEY REFERENCES pieces(id) ON DELETE CASCADE, + media_id TEXT NOT NULL UNIQUE REFERENCES piece_media(id) ON DELETE RESTRICT +); + +CREATE TRIGGER validate_piece_presentation_media_insert +BEFORE INSERT ON piece_presentation_media +WHEN NOT EXISTS ( + SELECT 1 FROM piece_media + WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL +) +BEGIN + SELECT RAISE(ABORT, 'presentation media does not belong to piece'); +END; + +CREATE TRIGGER validate_piece_presentation_media_update +BEFORE UPDATE ON piece_presentation_media +WHEN NOT EXISTS ( + SELECT 1 FROM piece_media + WHERE id = NEW.media_id AND piece_id = NEW.piece_id AND removed_at_utc IS NULL +) +BEGIN + SELECT RAISE(ABORT, 'presentation media does not belong to piece'); +END; + +ALTER TABLE usage_topic_snapshots ADD COLUMN icon_name TEXT NOT NULL DEFAULT ''; + +CREATE TABLE usage_media_snapshots ( + id TEXT PRIMARY KEY, + usage_id TEXT NOT NULL REFERENCES piece_usages(id) ON DELETE CASCADE, + original_media_id TEXT NOT NULL REFERENCES piece_media(id) ON DELETE RESTRICT, + alt_text TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL CHECK (position >= 0), + is_presentation INTEGER NOT NULL DEFAULT 0 CHECK (is_presentation IN (0, 1)), + UNIQUE (usage_id, position) +); + +CREATE INDEX idx_pieces_deleted_at ON pieces(deleted_at_utc); +CREATE INDEX idx_piece_media_active ON piece_media(piece_id, position) + WHERE removed_at_utc IS NULL; +CREATE INDEX idx_usage_media_usage ON usage_media_snapshots(usage_id, position); +CREATE UNIQUE INDEX idx_one_presentation_media_per_usage + ON usage_media_snapshots(usage_id) WHERE is_presentation = 1; diff --git a/migrations/0011_tool_icons.sql b/migrations/0011_tool_icons.sql new file mode 100644 index 0000000..1a32102 --- /dev/null +++ b/migrations/0011_tool_icons.sql @@ -0,0 +1 @@ +ALTER TABLE tools ADD COLUMN icon_name TEXT NOT NULL DEFAULT 'wrench'; diff --git a/package-lock.json b/package-lock.json index 5bbbecc..23f6cb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.5.9", + "version": "0.5.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.5.9", + "version": "0.5.10", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index 1671a5e..162ccab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.5.9", + "version": "0.5.10", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/scripts/generate-classic-regular-icons.mjs b/scripts/generate-classic-regular-icons.mjs new file mode 100644 index 0000000..20ab89b --- /dev/null +++ b/scripts/generate-classic-regular-icons.mjs @@ -0,0 +1,51 @@ +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const sourceRoot = process.argv[2]; +if (!sourceRoot) { + throw new Error('Usage: node scripts/generate-classic-regular-icons.mjs '); +} + +const javascript = readFileSync(path.join(sourceRoot, 'js', 'regular.js'), 'utf8'); +const metadata = JSON.parse(readFileSync(path.join(sourceRoot, 'metadata', 'icons.json'), 'utf8')); +const start = javascript.indexOf(' var icons = ') + ' var icons = '.length; +const end = javascript.indexOf('\n };', start) + '\n }'.length; +if (start < ' var icons = '.length || end < start) + throw new Error('Classic Regular icons not found'); + +const definitions = JSON.parse(javascript.slice(start, end)); +const icons = Object.keys(definitions) + .map((name) => { + const details = metadata[name]; + return { + name, + label: details?.label ?? name.replaceAll('-', ' '), + terms: details?.search?.terms ?? [] + }; + }) + .sort((left, right) => left.label.localeCompare(right.label, 'en')); + +const output = `// Generated from Font Awesome Pro Plus 7.0.0 Classic Regular metadata. +// Covered by the Font Awesome Pro commercial license; see THIRD_PARTY_NOTICES.md. +export type ClassicRegularIcon = { +\tname: string; +\tlabel: string; +\tterms: readonly string[]; +}; + +export const classicRegularIcons: readonly ClassicRegularIcon[] = ${JSON.stringify(icons)}; + +export type IconName = string; + +export const classicRegularIconNames = new Set( + classicRegularIcons.map((icon) => icon.name) +); +`; + +writeFileSync('src/lib/icons/classic-regular.ts', output); +mkdirSync('static/icons', { recursive: true }); +copyFileSync( + path.join(sourceRoot, 'sprites-full', 'regular.svg'), + 'static/icons/classic-regular.svg' +); +console.log(`Generated ${icons.length} Classic Regular icons.`); diff --git a/src/app.css b/src/app.css index c081473..350ac0d 100644 --- a/src/app.css +++ b/src/app.css @@ -3502,6 +3502,19 @@ textarea:focus { font-size: 1rem; } +.show-create-panel > summary { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 760; + list-style: none; +} + +.show-create-heading { + margin-top: 1rem; +} + .show-create-form { display: grid; grid-template-columns: minmax(12rem, 1.3fr) minmax(11rem, 0.85fr) minmax(10rem, 0.85fr) auto; @@ -3510,6 +3523,51 @@ textarea:focus { margin-top: 0.9rem; } +.show-template-grid { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.55rem; + padding: 0; + border: 0; +} + +.show-template-grid legend { + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.show-template-grid label { + display: grid; + position: relative; + grid-template-columns: auto auto 1fr; + align-items: start; + gap: 0.55rem; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-soft); + cursor: pointer; +} + +.show-template-grid label:has(input:checked) { + border-color: var(--gold); + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); +} + +.show-template-grid span, +.show-template-grid small { + display: block; +} + +.show-template-grid small { + margin-top: 0.15rem; + color: var(--text-muted); + font-size: 0.64rem; +} + .show-create-form label, .show-settings-form label, .section-edit-form label, @@ -4813,10 +4871,11 @@ textarea:focus { inset: 0 0 auto; z-index: 30; display: flex; - height: var(--mobile-header); + height: calc(var(--mobile-header) + env(safe-area-inset-top)); align-items: center; justify-content: space-between; - padding: 0 0.85rem 0 0.45rem; + padding: env(safe-area-inset-top) calc(0.85rem + env(safe-area-inset-right)) 0 + calc(0.45rem + env(safe-area-inset-left)); border-bottom: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line)); background: var(--glass); box-shadow: 0 8px 28px rgb(0 0 0 / 12%); @@ -4840,7 +4899,8 @@ textarea:focus { display: grid; height: calc(var(--mobile-nav) + env(safe-area-inset-bottom)); grid-template-columns: repeat(4, 1fr); - padding: 0.45rem 0.35rem env(safe-area-inset-bottom); + padding: 0.45rem calc(0.35rem + env(safe-area-inset-right)) env(safe-area-inset-bottom) + calc(0.35rem + env(safe-area-inset-left)); border-top: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line)); background: var(--glass); box-shadow: 0 -8px 28px rgb(0 0 0 / 12%); @@ -4872,11 +4932,13 @@ textarea:focus { } #main-content { margin-left: 0; - padding: var(--mobile-header) 0 calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 1rem); + padding: calc(var(--mobile-header) + env(safe-area-inset-top)) 0 + calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 1rem); } .page-wrap { width: 100%; - padding: 1.25rem 1rem 2rem; + padding: 1.25rem calc(1rem + env(safe-area-inset-right)) 2rem + calc(1rem + env(safe-area-inset-left)); } .page-heading h1 { font-size: 1.75rem; @@ -5072,15 +5134,16 @@ textarea:focus { border-radius: 0; } .piece-dialog-header { - padding: 0.75rem 0.8rem 0.6rem; + padding: calc(0.75rem + env(safe-area-inset-top)) calc(0.8rem + env(safe-area-inset-right)) + 0.6rem calc(0.8rem + env(safe-area-inset-left)); } .piece-dialog-heading > small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .piece-dialog-title textarea { - height: 2.35em; + .inline-title-trigger, + .inline-title-editor input:not([type='hidden']) { font-size: 1.3rem; line-height: 1.12; } @@ -5402,3 +5465,660 @@ textarea:focus { animation-iteration-count: 1 !important; } } + +/* Shared editor controls introduced for the compact piece workflow. */ +html:has(.heinzlive-output.empty), +body:has(.heinzlive-output.empty), +.heinzlive-output.empty { + background: transparent !important; +} + +.heinzlive-output.empty { + min-height: 100vh; + padding: 0; +} + +.live-toolbar { + position: relative; + z-index: 10; +} + +.live-output-settings[open] { + z-index: 50; +} + +.live-output-settings > div { + z-index: 50; +} + +.template-popover { + width: min(64rem, calc(100vw - var(--sidebar-width) - 4rem)); + max-height: calc(100dvh - 8rem); + overflow-y: auto; +} + +.template-grid { + grid-template-columns: repeat(auto-fit, minmax(11.5rem, 1fr)); +} + +.piece-primary h2 a { + position: static; +} + +.piece-card :is(button, input, summary), +.piece-card a:not(.piece-primary h2 a) { + position: relative; + z-index: 2; +} + +.inbox-item > a { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.inbox-item > a > span:first-child { + display: grid; +} + +.inbox-item > a > span:last-child { + color: var(--accent); + font-weight: 720; +} + +.inline-title-editor { + min-width: 0; +} + +.inline-title-trigger { + display: flex; + max-width: 100%; + align-items: center; + gap: 0.55rem; + padding: 0.1rem 0; + border: 0; + background: transparent; + color: var(--text); + cursor: text; + font-size: clamp(1.25rem, 2.5vw, 1.85rem); + font-weight: 760; + letter-spacing: -0.035em; + line-height: 1.12; + text-align: left; +} + +.inline-title-trigger > span:first-child { + overflow-wrap: anywhere; +} + +.inline-title-trigger .app-icon { + color: var(--text-faint); +} + +.inline-title-editor label, +.inline-title-editor input:not([type='hidden']) { + display: block; + width: 100%; +} + +.inline-title-editor input:not([type='hidden']) { + padding: 0.16rem 0.45rem; + border: 1px solid var(--line-strong); + border-radius: 0.55rem; + background: var(--field); + color: var(--text); + font-size: clamp(1.25rem, 2.5vw, 1.85rem); + font-weight: 760; + letter-spacing: -0.035em; +} + +.choice-picker { + display: grid; + gap: 0.45rem; +} + +.choice-picker-selected { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.choice-picker-selected > span { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--gold) 10%, var(--panel)); + color: var(--text); + font-size: 0.68rem; +} + +.choice-picker-menu, +.icon-picker details { + position: relative; +} + +.choice-picker-menu > summary, +.icon-picker summary { + width: fit-content; + cursor: pointer; + color: var(--accent); + font-size: 0.7rem; + font-weight: 720; + list-style: none; +} + +.choice-picker-popover, +.icon-picker-popover { + position: absolute; + z-index: 20; + top: calc(100% + 0.35rem); + left: 0; + width: min(32rem, calc(100vw - 3rem)); + max-height: min(28rem, 60dvh); + overflow: auto; + padding: 0.65rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-medium); + background: var(--panel-strong); + box-shadow: var(--shadow); +} + +.piece-subsection-heading .choice-picker-popover { + right: 0; + left: auto; +} + +.choice-picker-search { + display: grid !important; + grid-template-columns: auto 1fr; + align-items: center; +} + +.choice-picker-search input, +.icon-picker-popover input { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--field); + color: var(--text); +} + +.choice-picker-options { + display: grid; + gap: 0.3rem; + margin-top: 0.55rem; +} + +.choice-picker-options > label { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + cursor: pointer; +} + +.choice-picker-options > label.active { + border-color: color-mix(in srgb, var(--gold) 55%, var(--line)); + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); +} + +.choice-picker-options span, +.choice-picker-options small { + display: block; +} + +.choice-picker-close { + width: 100%; + justify-content: center; + margin-top: 0.55rem; + padding: 0.5rem; + border-top: 1px solid var(--line); +} + +.choice-picker-options small { + color: var(--text-faint); + font-size: 0.62rem; +} + +.icon-picker summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.icon-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); + gap: 0.35rem; + margin-top: 0.55rem; +} + +.icon-picker-grid button { + display: grid; + min-height: 4.5rem; + place-items: center; + gap: 0.25rem; + padding: 0.45rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-soft); + cursor: pointer; + font-size: 0.58rem; +} + +.icon-picker-grid button.active { + border-color: var(--gold); + color: var(--gold-strong); +} + +.topic-settings-list { + display: grid; + gap: 0.35rem; + margin: 0.8rem 0; +} + +.topic-settings-list > details { + padding: 0.55rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); +} + +.topic-settings-list summary { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.55rem; + cursor: pointer; + list-style: none; +} + +.topic-edit-form { + display: grid; + gap: 0.6rem; + margin: 0.7rem 0; +} + +.topic-edit-form label { + display: grid; + gap: 0.25rem; + font-size: 0.68rem; + font-weight: 700; +} + +.topic-edit-form input { + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--field); + color: var(--text); +} + +.tool-management { + padding: clamp(0.85rem, 2vw, 1.2rem); +} + +.tool-management .panel-heading { + align-items: center; +} + +.compact-add-button { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 0.4rem; + padding: 0.48rem 0.7rem; + border: 1px solid color-mix(in srgb, var(--gold) 42%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--gold) 10%, var(--glass)); + color: var(--gold-strong); + cursor: pointer; + font-size: 0.7rem; + font-weight: 760; +} + +.catalog-note { + margin: 0.45rem 0 0.85rem; + color: var(--text-faint); + font-size: 0.68rem; +} + +.catalog-settings-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.45rem; +} + +.catalog-settings-list > button { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + min-width: 0; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: color-mix(in srgb, var(--panel-soft) 82%, transparent); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.catalog-settings-list > button:hover { + border-color: color-mix(in srgb, var(--gold) 44%, var(--line)); + background: color-mix(in srgb, var(--gold) 7%, var(--panel-soft)); +} + +.catalog-settings-list > button.inactive { + opacity: 0.68; +} + +.catalog-entry-icon, +.management-dialog-icon { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--gold) 26%, var(--line)); + border-radius: 0.72rem; + background: color-mix(in srgb, var(--gold) 9%, var(--panel)); + color: var(--gold-strong); + font-size: 1rem; +} + +.catalog-entry-copy, +.catalog-entry-copy strong, +.catalog-entry-copy small { + display: block; + min-width: 0; +} + +.catalog-entry-copy strong { + font-size: 0.78rem; + overflow-wrap: anywhere; +} + +.catalog-entry-copy small { + margin-top: 0.12rem; + color: var(--text-faint); + font-size: 0.62rem; + line-height: 1.35; +} + +.catalog-settings-list > button > .app-icon { + color: var(--text-faint); +} + +.management-dialog { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(40rem, calc(100vw - 2rem)); + max-height: min(46rem, calc(100dvh - 2rem)); + padding: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--gold) 42%, var(--line)); + border-radius: var(--radius-large); + background: var(--glass-strong); + color: var(--text); + box-shadow: var(--shadow); + backdrop-filter: blur(24px) saturate(1.12); +} + +.management-dialog::backdrop { + background: rgb(3 8 18 / 46%); + backdrop-filter: blur(6px); +} + +.management-dialog > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--line); +} + +.management-dialog-title { + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; +} + +.management-dialog-title h2 { + margin: 0.1rem 0 0; + font-size: 1.05rem; + overflow-wrap: anywhere; +} + +.management-dialog-form { + display: grid; + min-height: 0; + overflow-y: auto; +} + +.management-dialog-fields, +.management-dialog-selections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; + padding: 1rem 1.1rem; +} + +.management-dialog-selections { + padding-top: 0; +} + +.management-dialog-fields label, +.management-dialog-selections > div, +.field-label { + display: grid; + gap: 0.3rem; + font-size: 0.68rem; + font-weight: 720; +} + +.management-dialog-fields .wide-field, +.management-dialog-selections .toggle-row { + grid-column: 1 / -1; +} + +.management-dialog input:not([type='checkbox'], [type='hidden']), +.management-dialog textarea { + width: 100%; + padding: 0.62rem 0.68rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + background: var(--field); + color: var(--text); + font: inherit; +} + +.management-dialog textarea { + min-height: 6.5rem; + resize: vertical; +} + +.management-dialog .toggle-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--panel-soft); + cursor: pointer; +} + +.management-dialog .toggle-row span, +.management-dialog .toggle-row strong, +.management-dialog .toggle-row small { + display: block; +} + +.management-dialog .toggle-row small { + margin-top: 0.12rem; + color: var(--text-faint); + font-size: 0.62rem; + font-weight: 500; +} + +.management-dialog footer { + display: flex; + position: sticky; + bottom: 0; + justify-content: flex-end; + gap: 0.55rem; + padding: 0.8rem 1.1rem calc(0.8rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--glass-strong) 94%, transparent); + backdrop-filter: blur(18px); +} + +.icon-picker-hint { + margin: 0.6rem 0 0; + color: var(--text-faint); + font-size: 0.62rem; +} + +.tool-assignment-name { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.45rem; +} + +.tool-assignment-name > span, +.tool-assignment-name strong, +.tool-assignment-name small { + display: block; +} + +.media-presentation-choice { + display: grid; + position: relative; + height: 100%; + cursor: pointer; +} + +.media-presentation-choice input { + position: absolute; + z-index: 2; + top: 0.55rem; + left: 0.55rem; +} + +.media-presentation-choice span, +.media-none { + padding: 0.45rem; + font-size: 0.68rem; + font-weight: 700; +} + +.media-presentation-choice:has(input:checked) { + box-shadow: inset 0 0 0 2px var(--gold); +} + +.upload-form .upload-select { + display: flex; + grid-column: 1 / -1; + align-items: center; + gap: 0.45rem; +} + +.upload-form .upload-select input { + width: auto; + min-height: auto; +} + +.usage-media-picker { + margin-top: 0.8rem; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); +} + +.usage-media-picker > label { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.template-save-menu > form { + max-height: min(30rem, calc(100dvh - 6rem)); + overflow-y: auto; +} + +.deleted-pieces { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--line); +} + +@media (max-width: 52rem) { + .choice-picker-popover, + .icon-picker-popover { + position: fixed; + inset: calc(var(--mobile-header) + env(safe-area-inset-top) + 0.5rem) + calc(0.5rem + env(safe-area-inset-right)) + calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 0.5rem) + calc(0.5rem + env(safe-area-inset-left)); + width: auto; + max-height: none; + } + + .piece-subsection-heading .choice-picker-popover { + right: calc(0.5rem + env(safe-area-inset-right)); + left: calc(0.5rem + env(safe-area-inset-left)); + } + + .choice-picker-selected { + max-height: 4.5rem; + overflow-y: auto; + } + + .tool-management .panel-heading { + align-items: flex-start; + } + + .catalog-settings-list { + grid-template-columns: 1fr; + } + + .management-dialog { + height: calc(100dvh - 1rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + width: auto; + max-width: none; + max-height: none; + inset: calc(0.5rem + env(safe-area-inset-top)) calc(0.5rem + env(safe-area-inset-right)) + calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-left)); + margin: 0; + } + + .management-dialog-form { + max-height: none; + } + + .management-dialog-fields, + .management-dialog-selections { + grid-template-columns: 1fr; + } + + .management-dialog-fields .wide-field, + .management-dialog-selections .toggle-row { + grid-column: auto; + } + + .management-dialog footer button { + flex: 1; + } +} diff --git a/src/lib/components/AppIcon.svelte b/src/lib/components/AppIcon.svelte new file mode 100644 index 0000000..4c5bd1c --- /dev/null +++ b/src/lib/components/AppIcon.svelte @@ -0,0 +1,23 @@ + + + + + + + diff --git a/src/lib/components/ChoicePicker.svelte b/src/lib/components/ChoicePicker.svelte new file mode 100644 index 0000000..b3bf42b --- /dev/null +++ b/src/lib/components/ChoicePicker.svelte @@ -0,0 +1,90 @@ + + +
+ {#if fieldName} + {#each selectedIds as id (id)}{/each} + {/if} + {#if selected.length} +
+ {#each selected as item (item.id)} + {#if item.iconName}{/if}{item.name} + {/each} +
+ {/if} +
+ {selected.length ? `${label} ändern` : `${label} auswählen`} +
+ +
+ {#each filtered as item (item.id)} + + {:else}

Keine Treffer.

{/each} +
+ +
+
+
diff --git a/src/lib/components/IconPicker.svelte b/src/lib/components/IconPicker.svelte new file mode 100644 index 0000000..8cd7832 --- /dev/null +++ b/src/lib/components/IconPicker.svelte @@ -0,0 +1,50 @@ + + +
+ +
+ Icon auswählen +
+ +
+ {#each visible as icon (icon.name)} + + {/each} +
+ {#if filtered.length > visible.length}

+ {visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu + finden. +

{/if} +
+
+
diff --git a/src/lib/components/InlineTitleEditor.svelte b/src/lib/components/InlineTitleEditor.svelte new file mode 100644 index 0000000..f01373d --- /dev/null +++ b/src/lib/components/InlineTitleEditor.svelte @@ -0,0 +1,70 @@ + + +
+ + {#if editing} + + {:else} + + {/if} +
diff --git a/src/lib/components/MarkdownEditorField.svelte b/src/lib/components/MarkdownEditorField.svelte index f8d5fd6..a9fd410 100644 --- a/src/lib/components/MarkdownEditorField.svelte +++ b/src/lib/components/MarkdownEditorField.svelte @@ -1,5 +1,6 @@ - + diff --git a/src/lib/components/PieceEditorDialog.svelte b/src/lib/components/PieceEditorDialog.svelte index a18a651..6a75338 100644 --- a/src/lib/components/PieceEditorDialog.svelte +++ b/src/lib/components/PieceEditorDialog.svelte @@ -1,5 +1,6 @@ + + event.target === dialog && close()} +> +
+
+ +
+

{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}

+

{tool?.name || 'Neues Werkzeug'}

+
+
+ +
+
+ {#if tool}{/if} +
+ + + +
+
+
+ Icon + +
+
+ Passende Formate + (formatIds = ids)} + /> +
+ +
+
+ + +
+
+
diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte index 57b4b72..2e91ac8 100644 --- a/src/lib/components/UsageEditorDialog.svelte +++ b/src/lib/components/UsageEditorDialog.svelte @@ -2,6 +2,7 @@ import { enhance, type SubmitFunction } from '$app/forms'; import { untrack } from 'svelte'; import MarkdownEditorField from './MarkdownEditorField.svelte'; + import InlineTitleEditor from './InlineTitleEditor.svelte'; import NavIcon from './NavIcon.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte'; import type { PieceUsage } from '#lib/server/shows.js'; @@ -29,6 +30,9 @@ untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) ); let dirty = $state(false); + let selectedMedia = $derived( + draft.media.find((media) => media.id === draft.presentationMediaId) ?? null + ); function durationInMinutes(seconds: number) { return Number((seconds / 60).toFixed(2)); @@ -67,18 +71,7 @@ > {#snippet header()}

Sendungsfassung

- + (dirty = true)} />

{draft.title}

{/snippet} @@ -250,6 +243,7 @@ value={draft.presentationSourceTimecodeSeconds ?? ''} /> + {#if draft.presentationMode !== 'text'}
@@ -297,6 +291,33 @@ {/if}
+ {#if draft.media.length}
+ Hochgeladenes Präsentationsbild + +
+ {#each draft.media as media (media.id)}{/each} +
+
{/if}
{:else}
- +

Keine Pieces archiviert

Archivierte Inhalte lassen sich hier später wiederherstellen.

{/if} +
+

Gelöschte Pieces

+

+ Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert. +

+ {#if data.deletedPieces.length}
+ {#each data.deletedPieces as piece (piece.id)}
+
+

{piece.title}

+ {#if piece.summary}

{piece.summary}

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

Keine gelöschten Pieces.

{/if} +
diff --git a/src/routes/eingang/+page.server.ts b/src/routes/eingang/+page.server.ts index 10adfb9..1f64624 100644 --- a/src/routes/eingang/+page.server.ts +++ b/src/routes/eingang/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; -import { captureInbox, listPieces } from '#lib/server/content.js'; +import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js'; export const load: PageServerLoad = ({ locals }) => { requireUser(locals.user); @@ -26,5 +26,15 @@ export const actions = { : 'Ein Titel ist erforderlich.' }); } + }, + delete: async ({ locals, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true); + return { deleted: true }; + } catch { + return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' }); + } } } satisfies Actions; diff --git a/src/routes/eingang/+page.svelte b/src/routes/eingang/+page.svelte index 8365b51..9d21df2 100644 --- a/src/routes/eingang/+page.svelte +++ b/src/routes/eingang/+page.svelte @@ -1,5 +1,6 @@ @@ -45,18 +46,37 @@ {#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} + {#each data.pieces as piece (piece.id)}{/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 3037620..c02bd31 100644 --- a/src/routes/fundus/+page.server.ts +++ b/src/routes/fundus/+page.server.ts @@ -9,6 +9,7 @@ import { listPieces, listPieceTemplates, setPieceArchived, + setPieceDeleted, setPieceFavorite } from '#lib/server/content.js'; import { getPieceUsageContexts } from '#lib/server/shows.js'; @@ -104,5 +105,18 @@ export const actions = { } catch (cause) { return fail(404, { intent: 'favorite', error: actionError(cause) }); } + }, + delete: async ({ locals, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true); + return { + intent: 'delete', + success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.' + }; + } catch (cause) { + return fail(409, { intent: 'delete', error: actionError(cause) }); + } } } satisfies Actions; diff --git a/src/routes/fundus/+page.svelte b/src/routes/fundus/+page.svelte index a7011b1..11e97c9 100644 --- a/src/routes/fundus/+page.svelte +++ b/src/routes/fundus/+page.svelte @@ -5,6 +5,7 @@ import { page } from '$app/state'; import { onMount, untrack } from 'svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; + import AppIcon from '#lib/components/AppIcon.svelte'; import { formatClock } from '#lib/format.js'; import type { PageProps } from './$types'; @@ -65,14 +66,16 @@ {visiblePieces.length}Inhalte
- Neues Piece + Neues Piece
{#each data.templates as template (template.id)}
{template.name}{template.description} {/each} @@ -110,7 +113,7 @@ >
{:else}
- +

Kein passendes Piece

Andere Filter wählen oder ein neues Piece anlegen.

Filter zurücksetzen diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts index 752d361..56975c8 100644 --- a/src/routes/fundus/[id]/+page.server.ts +++ b/src/routes/fundus/[id]/+page.server.ts @@ -11,6 +11,7 @@ import { savePiece, savePieceAsTemplate, setPieceArchived, + setPieceDeleted, setPieceFavorite, uploadPieceImage, type EditablePieceStatus, @@ -88,6 +89,7 @@ function pieceInput(data: FormData): PieceInput { presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1', presentationImageManual: text(data, 'presentationImageManual') === '1', presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null, + presentationMediaId: text(data, 'presentationMediaId') || null, topicIds: data.getAll('topicIds').map(String), categoryIds: data.getAll('categoryIds').map(String), sources, @@ -206,7 +208,13 @@ export const actions = { if (!(image instanceof File)) return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' }); try { - await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText')); + await uploadPieceImage( + params.id, + Number(data.get('revision')), + image, + text(data, 'altText'), + data.get('selectForPresentation') === '1' + ); } catch (cause) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') @@ -264,5 +272,15 @@ export const actions = { } catch (cause) { return fail(404, { intent: 'favorite', error: saveError(cause).error }); } + }, + delete: async ({ locals, params, request }) => { + requireUser(locals.user); + const data = await request.formData(); + try { + setPieceDeleted(params.id, Number(data.get('revision')), true); + } catch (cause) { + return fail(409, { intent: 'delete', error: saveError(cause).error }); + } + redirect(303, '/fundus'); } } satisfies Actions; diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index c9c5053..345b944 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -4,6 +4,9 @@ import { resolve } from '$app/paths'; import { onDestroy, onMount, untrack } from 'svelte'; import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte'; + import AppIcon from '#lib/components/AppIcon.svelte'; + import ChoicePicker from '#lib/components/ChoicePicker.svelte'; + import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte'; import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte'; import PresentationDisplay from '#lib/components/PresentationDisplay.svelte'; import type { PresentationContent } from '#lib/server/presentation.js'; @@ -15,7 +18,6 @@ let revision = $state(untrack(() => data.piece.revision)); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool })))); - let toolToAdd = $state(''); let rendered = $state(untrack(() => ({ ...data.rendered }))); let activeSection = $state<'script' | 'research' | 'presentation'>('script'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); @@ -44,7 +46,12 @@ ? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id }) : '' ); - let previewContent = $derived({ + let selectedMedia = $derived( + draft.media.find((media) => media.id === draft.presentationMediaId) ?? null + ); + let previewContent = $derived< + PresentationContent & { images: { id: string; altText: string }[] } + >({ mode: draft.presentationMode, topics: data.taxonomy.topics .filter((topic) => draft.topicIds.includes(topic.id)) @@ -69,8 +76,16 @@ ? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}` : draft.presentationProvider === 'tiktok' && draft.presentationProviderId ? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}` - : '' + : '', + images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : [] }); + let presentationPreviewImage = $derived( + selectedMedia + ? draft.presentationMode === 'text' + ? '/media' + : `/media/${selectedMedia.id}` + : sourcePreviewImage + ); let pendingHref: string | null = null; let allowNavigation = false; @@ -152,24 +167,28 @@ scheduleSave(); } - function addTool() { - const tool = data.tools.find((item) => item.id === toolToAdd); - if (!tool || pieceTools.some((item) => item.toolId === tool.id)) return; - pieceTools.push({ - id: crypto.randomUUID(), - toolId: tool.id, - name: tool.name, - startUrl: tool.startUrl, - isActive: tool.isActive, - workUrl: '', - note: '' - }); - toolToAdd = ''; + function removeTool(index: number) { + pieceTools.splice(index, 1); scheduleSave(); } - function removeTool(index: number) { - pieceTools.splice(index, 1); + function selectTools(ids: string[]) { + for (const id of ids) { + if (pieceTools.some((tool) => tool.toolId === id)) continue; + const tool = data.tools.find((item) => item.id === id); + if (tool) + pieceTools.push({ + id: crypto.randomUUID(), + toolId: tool.id, + name: tool.name, + startUrl: tool.startUrl, + iconName: tool.iconName, + isActive: tool.isActive, + workUrl: '', + note: '' + }); + } + pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId)); scheduleSave(); } @@ -244,22 +263,15 @@ class:active={data.piece.isFavorite} type="submit" aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'} - aria-pressed={data.piece.isFavorite}>★
- +

{draft.title}

{/snippet} @@ -345,17 +357,16 @@ bind:value={draft.summary}>
- Themen -
- {#each data.taxonomy.topics as item (item.id)}{/each} -
+ Themen { + draft.topicIds = ids; + scheduleSave(); + }} + />
Vorgeschlagene Abschnittskategorien @@ -440,22 +451,20 @@

Arbeitsmittel

Werkzeuge

-
- - - -
+ tool.isActive) + .map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + iconName: tool.iconName, + meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : '' + }))} + selectedIds={pieceTools.map((tool) => tool.toolId)} + label="Werkzeuge" + onselectionchange={selectTools} + />
{#if pieceTools.length}
{#each pieceTools as tool, index (tool.id)}
- {tool.name}{#if !tool.isActive}Deaktiviert{/if} + {tool.name}{#if !tool.isActive}Deaktiviert{/if}
@@ -620,17 +632,44 @@ -
{ piece.presentationProviderId, source?.timecodeSeconds ?? null ), - sources: piece.sources, - images: piece.media.map((item) => ({ id: item.id, altText: item.altText })) + sources: [], + images: piece.media + .filter((item) => item.id === piece.presentationMediaId) + .map((item) => ({ id: item.id, altText: item.altText })) }; return { pieceId: piece.id, content }; }; diff --git a/src/routes/fundus/[id]/praesentation/+page.svelte b/src/routes/fundus/[id]/praesentation/+page.svelte index 343c22f..fea61b8 100644 --- a/src/routes/fundus/[id]/praesentation/+page.svelte +++ b/src/routes/fundus/[id]/praesentation/+page.svelte @@ -14,8 +14,10 @@
Isolierte Testansicht
diff --git a/src/routes/heinzlive/[token]/+page.svelte b/src/routes/heinzlive/[token]/+page.svelte index 9003180..f392c43 100644 --- a/src/routes/heinzlive/[token]/+page.svelte +++ b/src/routes/heinzlive/[token]/+page.svelte @@ -56,7 +56,7 @@ -
+
{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
@@ -64,16 +64,12 @@ {#key view.revision} {/key} - {:else} -
- -

Bereit für den nächsten Inhalt

-

Die Moderation wählt ausdrücklich aus, was hier erscheint.

-
{/if}
diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index 83bfffb..a50fa55 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import { onMount, untrack } from 'svelte'; import { formatClock, formatDuration } from '#lib/format.js'; + import AppIcon from '#lib/components/AppIcon.svelte'; import type { PageProps } from './$types'; let { data, form }: PageProps = $props(); @@ -183,6 +184,17 @@ + {#if data.live.presentationUsageId}
+ + + + + +
{/if}
Ausgabekanal
@@ -307,17 +319,6 @@ : 'Präsentieren'} - {#if data.live.presentationUsageId}
- - - - - -
{/if}
@@ -412,7 +413,7 @@ type="submit" disabled={usageIndex === 0 || section.usages[usageIndex - 1]?.status !== 'open'} - aria-label={`${usage.title} nach oben`}>↑
↓ {/if} @@ -438,7 +440,9 @@ - +
@@ -465,7 +469,8 @@
@@ -477,7 +482,8 @@ {#if data.live.current || data.live.sections[0]}
import { resolve } from '$app/paths'; import { formatDuration } from '#lib/format.js'; + import AppIcon from '#lib/components/AppIcon.svelte'; import type { PageProps } from './$types'; let { data, form }: PageProps = $props(); @@ -27,8 +28,9 @@ {#if form?.error}{/if} -
-
+
+ Neue Sendung planen +

Neuen Stream planen

Leer starten oder Ablauf übernehmen

@@ -45,23 +47,29 @@ - +
+ Ausgangspunkt + + {#each data.templates as template (template.id)}{/each} +
-
- {#each data.templates as template (template.id)}

- {template.name}: - {template.description} -

{/each} -
-
+
@@ -86,16 +94,17 @@

{berlinDate(show.startsAtUtc)} Uhr

- {show.sectionCount} Abschnitte{formatDuration(show.totalDurationSeconds)} Planzeit + {#if show.sectionCount}{show.sectionCount} Abschnitte{/if}{#if show.totalDurationSeconds}{formatDuration(show.totalDurationSeconds)} Planzeit{/if}
{/each} {:else}
- +

Noch keine Sendung

Oben kann der erste Stream geplant werden.

diff --git a/src/routes/sendungen/[id]/+page.server.ts b/src/routes/sendungen/[id]/+page.server.ts index 070ed2d..c4a76c7 100644 --- a/src/routes/sendungen/[id]/+page.server.ts +++ b/src/routes/sendungen/[id]/+page.server.ts @@ -92,6 +92,16 @@ function actionError(cause: unknown) { if (code === 'ORIGINAL_NOT_FOUND') return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; + if ( + ['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes( + code + ) + ) + return { + status: 409, + error: + 'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.' + }; if (code === 'INVALID_DURATION') return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; if (code.startsWith('INVALID_')) @@ -239,6 +249,7 @@ export const actions = { presentationDescription: text(data, 'presentationDescription'), presentationImageUrl: text(data, 'presentationImageUrl'), presentationSiteName: text(data, 'presentationSiteName'), + presentationMediaId: text(data, 'presentationMediaId') || null, tools: toolIds.map((id, index) => ({ id, workUrl: toolWorkUrls[index] ?? '', diff --git a/src/routes/sendungen/[id]/+page.svelte b/src/routes/sendungen/[id]/+page.svelte index b042676..ebe4b63 100644 --- a/src/routes/sendungen/[id]/+page.svelte +++ b/src/routes/sendungen/[id]/+page.svelte @@ -6,6 +6,7 @@ import { flip } from 'svelte/animate'; import { onMount, untrack } from 'svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; + import AppIcon from '#lib/components/AppIcon.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import { formatClock, formatDuration } from '#lib/format.js'; import type { PageProps } from './$types'; @@ -207,7 +208,7 @@
- ← Sendungen + Sendungen

{data.show.title}

{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} @@ -219,7 +220,7 @@ {formatDuration(data.show.totalDurationSeconds)}Planzeit

Live-Steuerung ↗Live-Steuerung
@@ -412,7 +413,8 @@ >{formatClock(piece.durationSeconds)}

- {#if piece.isFavorite}★{/if}{piece.title}

@@ -449,7 +451,7 @@ {/each} {#if !visiblePieces.length}

- +

Keine verfügbaren Pieces

Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig @@ -525,7 +527,7 @@ />

@@ -536,7 +538,7 @@ />
@@ -602,7 +604,8 @@ />
@@ -617,7 +620,8 @@ /> { + 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 { + updateTaxonomy( + kind, + String(data.get('taxonomyId') || ''), + String(data.get('name') || ''), + String(data.get('iconName') || '') + ); + return { intent: 'taxonomy', success: 'Eintrag aktualisiert.' }; + } catch (cause) { + return fail(400, { intent: 'taxonomy', error: actionError(cause) }); + } + }, deleteTaxonomy: async ({ locals, request }) => { requireBarkeeper(locals.user); const data = await request.formData(); diff --git a/src/routes/verwaltung/+page.svelte b/src/routes/verwaltung/+page.svelte index 24f2e6c..20ad8b7 100644 --- a/src/routes/verwaltung/+page.svelte +++ b/src/routes/verwaltung/+page.svelte @@ -1,6 +1,10 @@