Release 0.5.10

This commit is contained in:
hnzio committed 2026-10-23 21:33:15 +02:00
1 parent c3033479b5
commit 4fe61971e4
52 files changed
+38528 -429

No files matched your search

+11
View File
@@ -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.
+42
View File
@@ -370,3 +370,45 @@
- `bash -n start.sh` und `git diff --check`: erfolgreich - `bash -n start.sh` und `git diff --check`: erfolgreich
- reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt; - reale OBS-Browserquelle und echte YouTube-/TikTok-Wiedergabe nicht ausgeführt;
Anbieterrequests wurden in den Browsertests lokal abgefangen 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
+25 -7
View File
@@ -38,11 +38,11 @@ Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte
und spätere Bereiche. und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Favorit, Dauer, Revision, Texten, Quellen, Werkzeugzuordnungen und privaten Medien; Vorlagen speichern Snapshots | | 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-/Themensnapshots, Reservebank und Vorlagen; Original-ID bleibt Referenz | | 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 | | 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 | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | | Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
@@ -106,7 +106,9 @@ unabhängige Zuordnung. Quellen werden gemeinsam mit einem Piece atomar ersetzt.
Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate Historienmarkierung. Der Werkzeugkatalog liegt getrennt von Formaten; Formate
sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten sind nur Eignungszuordnungen. `piece_tools` hält pro Piece den privaten
Arbeitslink und eine Notiz, während Deaktivierung das Katalogobjekt und alle 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 Ein Piece kann für die Präsentation Text oder genau eine vorhandene Quelle als
Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen Linkkarte beziehungsweise Video auswählen. Abgerufene Metadaten und Kennzeichen
für manuell korrigierte Felder liegen am Piece; ein erneuter Abruf überschreibt 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`, prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`,
Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus. 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 ## Sendungsbau
Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine 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 Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke, Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz sowie die Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
Themennamen. Eigene Notizen liegen ausschließlich an 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 der Verwendung. Die Referenz zum Original bleibt für Historie und
Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. Eine explizite
Aktualisierung aus dem Fundus ersetzt Titel, Dauer, alle fünf Redaktionstexte 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 Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation. 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 Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung, Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche. Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
+26
View File
@@ -334,3 +334,29 @@ Eintritt wird eine positive explizite Abschnittsdauer eingefroren. Weitere
Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen Pieces desselben Abschnitts nutzen denselben Timer; Abschnittswechsel schließen
ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene ihn. So bleiben Reload und Wiederaufnahme persistent, ohne die vorhandene
Piece-Zeiterfassung doppelt zu zählen. 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`.
+55
View File
@@ -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.
+28
View File
@@ -7,6 +7,34 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen. 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 ## Live-Werkbank 0.5.9
- globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete - globale Favoriten am Fundus-Original mit stabiler Vorsortierung; verwendete
+49
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE tools ADD COLUMN icon_name TEXT NOT NULL DEFAULT 'wrench';
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.9", "version": "0.5.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.9", "version": "0.5.10",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.9", "version": "0.5.10",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
@@ -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 <font-awesome-web-root>');
}
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<string>(
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.`);
+728 -8
View File
@@ -3502,6 +3502,19 @@ textarea:focus {
font-size: 1rem; 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 { .show-create-form {
display: grid; display: grid;
grid-template-columns: minmax(12rem, 1.3fr) minmax(11rem, 0.85fr) minmax(10rem, 0.85fr) auto; 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; 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-create-form label,
.show-settings-form label, .show-settings-form label,
.section-edit-form label, .section-edit-form label,
@@ -4813,10 +4871,11 @@ textarea:focus {
inset: 0 0 auto; inset: 0 0 auto;
z-index: 30; z-index: 30;
display: flex; display: flex;
height: var(--mobile-header); height: calc(var(--mobile-header) + env(safe-area-inset-top));
align-items: center; align-items: center;
justify-content: space-between; 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)); border-bottom: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line));
background: var(--glass); background: var(--glass);
box-shadow: 0 8px 28px rgb(0 0 0 / 12%); box-shadow: 0 8px 28px rgb(0 0 0 / 12%);
@@ -4840,7 +4899,8 @@ textarea:focus {
display: grid; display: grid;
height: calc(var(--mobile-nav) + env(safe-area-inset-bottom)); height: calc(var(--mobile-nav) + env(safe-area-inset-bottom));
grid-template-columns: repeat(4, 1fr); 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)); border-top: 1px solid color-mix(in srgb, var(--gold) 18%, var(--line));
background: var(--glass); background: var(--glass);
box-shadow: 0 -8px 28px rgb(0 0 0 / 12%); box-shadow: 0 -8px 28px rgb(0 0 0 / 12%);
@@ -4872,11 +4932,13 @@ textarea:focus {
} }
#main-content { #main-content {
margin-left: 0; 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 { .page-wrap {
width: 100%; 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 { .page-heading h1 {
font-size: 1.75rem; font-size: 1.75rem;
@@ -5072,15 +5134,16 @@ textarea:focus {
border-radius: 0; border-radius: 0;
} }
.piece-dialog-header { .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 { .piece-dialog-heading > small {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.piece-dialog-title textarea { .inline-title-trigger,
height: 2.35em; .inline-title-editor input:not([type='hidden']) {
font-size: 1.3rem; font-size: 1.3rem;
line-height: 1.12; line-height: 1.12;
} }
@@ -5402,3 +5465,660 @@ textarea:focus {
animation-iteration-count: 1 !important; 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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
</script>
<svg
width={size}
height={size}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
class="app-icon"
>
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
</svg>
<style>
.app-icon {
display: inline-block;
flex: none;
fill: currentColor;
vertical-align: -0.125em;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import AppIcon from './AppIcon.svelte';
type Item = {
id: string;
name: string;
iconName?: string;
description?: string;
meta?: string;
};
let {
items,
selectedIds,
label,
fieldName,
onselectionchange
}: {
items: Item[];
selectedIds: string[];
label: string;
fieldName?: string;
onselectionchange: (ids: string[]) => void;
} = $props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
items.filter((item) =>
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
function toggle(id: string) {
onselectionchange(
selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id]
);
}
</script>
<div class="choice-picker">
{#if fieldName}
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
{/if}
{#if selected.length}
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
{#each selected as item (item.id)}
<span
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
>
{/each}
</div>
{/if}
<details bind:this={menu} class="choice-picker-menu">
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
<div class="choice-picker-popover">
<label class="choice-picker-search">
<span class="sr-only">{label} durchsuchen</span>
<AppIcon name="magnifying-glass" />
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
</label>
<div class="choice-picker-options">
{#each filtered as item (item.id)}
<label class:active={selectedIds.includes(item.id)}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onchange={() => toggle(item.id)}
/>
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
<span
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
>{/if}</span
>
{#if item.meta}<small>{item.meta}</small>{/if}
</label>
{:else}<p>Keine Treffer.</p>{/each}
</div>
<button
class="choice-picker-close text-button"
type="button"
onclick={() => (menu.open = false)}>Auswahl schließen</button
>
</div>
</details>
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
import AppIcon from './AppIcon.svelte';
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
.toLocaleLowerCase('de')
.includes(query.trim().toLocaleLowerCase('de'))
)
);
let visible = $derived(filtered.slice(0, 120));
</script>
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
><span class="sr-only">Icons durchsuchen</span><input
type="search"
placeholder="Icon suchen"
bind:value={query}
/></label
>
<div class="icon-picker-grid">
{#each visible as icon (icon.name)}
<button
type="button"
class:active={name === icon.name}
title={icon.label}
onclick={() => {
name = icon.name;
menu.open = false;
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
>
{/each}
</div>
{#if filtered.length > visible.length}<p class="icon-picker-hint">
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
finden.
</p>{/if}
</div>
</details>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let {
value = $bindable(),
form,
name = 'title',
label = 'Titel',
oncommit
}: {
value: string;
form: string;
name?: string;
label?: string;
oncommit?: () => void;
} = $props();
let editing = $state(false);
let original = $state('');
let input = $state<HTMLInputElement>();
let trigger = $state<HTMLButtonElement>();
async function edit() {
original = value;
editing = true;
await tick();
input?.focus();
input?.select();
}
async function finish(commit: boolean) {
if (!editing) return;
if (!commit) value = original;
editing = false;
if (commit && value !== original) oncommit?.();
await tick();
trigger?.focus();
}
</script>
<div class="inline-title-editor">
<input type="hidden" {form} {name} {value} />
{#if editing}
<label>
<span class="sr-only">{label}</span>
<input
bind:this={input}
bind:value
maxlength="200"
required
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
void finish(false);
}
}}
onblur={() => void finish(true)}
/>
</label>
{:else}
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
<span>{value}</span><AppIcon name="pen" size="0.9em" />
<span class="sr-only">{label} bearbeiten</span>
</button>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
let { let {
name, name,
@@ -60,12 +61,15 @@
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')} <button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
><em>I</em></button ><em>I</em></button
> >
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</button> <button type="button" aria-label="Liste" title="Liste" onclick={addList}
><AppIcon name="list-check" /> Liste</button
>
<button <button
type="button" type="button"
aria-label="Link" aria-label="Link"
title="Link" title="Link"
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}>↗ Link</button onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
><AppIcon name="link" /> Link</button
> >
</span> </span>
</div> </div>
+13 -26
View File
@@ -1,34 +1,21 @@
<script lang="ts"> <script lang="ts">
import AppIcon from './AppIcon.svelte';
let { name }: { name: string } = $props(); let { name }: { name: string } = $props();
const aliases: Record<string, string> = {
library: 'folder-open',
shows: 'calendar-days',
archive: 'box-archive',
settings: 'gear',
edit: 'pen'
};
let iconName = $derived(aliases[name] ?? name);
</script> </script>
<svg viewBox="0 0 24 24" aria-hidden="true"> <AppIcon name={iconName} size="1.25rem" />
{#if name === 'inbox'}
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
{:else if name === 'library'}
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
{:else if name === 'shows'}
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
{:else if name === 'archive'}
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
{:else if name === 'settings'}
<circle cx="12" cy="12" r="3" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if}
</svg>
<style> <style>
svg { :global(.app-icon) {
width: 1.25rem; display: block;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
} }
</style> </style>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick, type Snippet } from 'svelte'; import { tick, type Snippet } from 'svelte';
import AppIcon from './AppIcon.svelte';
let { let {
labelledby, labelledby,
@@ -53,7 +54,7 @@
class="dialog-close" class="dialog-close"
type="button" type="button"
aria-label="Editor schließen" aria-label="Editor schließen"
onclick={onrequestclose}>×</button onclick={onrequestclose}><AppIcon name="xmark" /></button
> >
</header> </header>
<nav class="piece-dialog-tabs" aria-label="Editorbereiche"> <nav class="piece-dialog-tabs" aria-label="Editorbereiche">
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type Preference = 'system' | 'light' | 'dark'; type Preference = 'system' | 'light' | 'dark';
const preferences: Preference[] = ['system', 'light', 'dark']; const preferences: Preference[] = ['system', 'light', 'dark'];
@@ -42,8 +43,10 @@
onclick={cycle} onclick={cycle}
aria-label={`Darstellung: ${labels[preference]}`} aria-label={`Darstellung: ${labels[preference]}`}
> >
<span class="theme-symbol" aria-hidden="true"> <span class="theme-symbol" aria-hidden="true"
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'} ><AppIcon
</span> name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
/></span
>
<span>{labels[preference]}</span> <span>{labels[preference]}</span>
</button> </button>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { tick } from 'svelte';
import AppIcon from './AppIcon.svelte';
import ChoicePicker from './ChoicePicker.svelte';
import IconPicker from './IconPicker.svelte';
type Format = { id: string; name: string };
type Tool = {
id: string;
name: string;
startUrl: string;
description: string;
iconName: string;
isActive: boolean;
formatIds: string[];
};
let { formats }: { formats: Format[] } = $props();
let dialog: HTMLDialogElement;
let nameInput: HTMLInputElement;
let tool = $state<Tool | null>(null);
let name = $state('');
let startUrl = $state('');
let description = $state('');
let iconName = $state('wrench');
let isActive = $state(true);
let formatIds = $state<string[]>([]);
export async function showModal(selectedTool: Tool | null = null) {
tool = selectedTool;
name = selectedTool?.name ?? '';
startUrl = selectedTool?.startUrl ?? '';
description = selectedTool?.description ?? '';
iconName = selectedTool?.iconName || 'wrench';
isActive = selectedTool?.isActive ?? true;
formatIds = [...(selectedTool?.formatIds ?? [])];
dialog.showModal();
await tick();
nameInput.focus();
}
function close() {
dialog.close();
}
</script>
<dialog
bind:this={dialog}
class="management-dialog tool-editor-dialog"
aria-labelledby="tool-dialog-title"
onclick={(event) => event.target === dialog && close()}
>
<header>
<div class="management-dialog-title">
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
<div>
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
</div>
</div>
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
><AppIcon name="xmark" /></button
>
</header>
<form
class="management-dialog-form"
method="POST"
action={tool ? '?/updateTool' : '?/createTool'}
>
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
<div class="management-dialog-fields">
<label
>Name<input
bind:this={nameInput}
bind:value={name}
name="name"
required
maxlength="120"
/></label
>
<label
>Start-URL<input
bind:value={startUrl}
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
></textarea></label
>
</div>
<div class="management-dialog-selections">
<div>
<span class="field-label">Icon</span>
<IconPicker bind:name={iconName} />
</div>
<div>
<span class="field-label">Passende Formate</span>
<ChoicePicker
items={formats}
selectedIds={formatIds}
label="Formate"
fieldName="formatIds"
onselectionchange={(ids) => (formatIds = ids)}
/>
</div>
<label class="toggle-row"
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
><strong>Aktiv</strong><small
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
></span
></label
>
</div>
<footer>
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
<button class="primary-button" type="submit"
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
>
</footer>
</form>
</dialog>
+44 -13
View File
@@ -2,6 +2,7 @@
import { enhance, type SubmitFunction } from '$app/forms'; import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte'; import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte'; import NavIcon from './NavIcon.svelte';
import PieceEditorDialog from './PieceEditorDialog.svelte'; import PieceEditorDialog from './PieceEditorDialog.svelte';
import type { PieceUsage } from '#lib/server/shows.js'; import type { PieceUsage } from '#lib/server/shows.js';
@@ -29,6 +30,9 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) })) untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
); );
let dirty = $state(false); let dirty = $state(false);
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
function durationInMinutes(seconds: number) { function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2)); return Number((seconds / 60).toFixed(2));
@@ -67,18 +71,7 @@
> >
{#snippet header()} {#snippet header()}
<p class="eyebrow">Sendungsfassung</p> <p class="eyebrow">Sendungsfassung</p>
<label class="piece-dialog-title"> <InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
<span class="sr-only">Titel</span>
<textarea
form={formId}
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title}
oninput={() => (dirty = true)}></textarea>
</label>
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2> <h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
{/snippet} {/snippet}
@@ -250,6 +243,7 @@
value={draft.presentationSourceTimecodeSeconds ?? ''} value={draft.presentationSourceTimecodeSeconds ?? ''}
/> />
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} /> <input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode !== 'text'} {#if draft.presentationMode !== 'text'}
<div class="presentation-settings"> <div class="presentation-settings">
<div class="presentation-source-summary wide-field"> <div class="presentation-source-summary wide-field">
@@ -297,6 +291,33 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} /> <input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if} {/if}
</div> </div>
{#if draft.media.length}<fieldset class="usage-media-picker">
<legend>Hochgeladenes Präsentationsbild</legend>
<label
><input
type="radio"
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
dirty = true;
}}
/> Kein Bild</label
>
<div class="media-grid">
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
><input
type="radio"
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
dirty = true;
}}
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
>Auswählen</span
></label
>{/each}
</div>
</fieldset>{/if}
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<p class="output-kicker">Vorschau · {draft.presentationMode}</p> <p class="output-kicker">Vorschau · {draft.presentationMode}</p>
<h4>{draft.outputTitle || draft.title}</h4> <h4>{draft.outputTitle || draft.title}</h4>
@@ -304,8 +325,18 @@
<p class="preview-markdown-source"> <p class="preview-markdown-source">
{draft.presentation || 'Noch kein öffentlicher Text.'} {draft.presentation || 'Noch kein öffentlicher Text.'}
</p> </p>
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else} {:else}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if} {#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
<p> <p>
{draft.presentationDescription || {draft.presentationDescription ||
draft.presentationSourceUrl || draft.presentationSourceUrl ||
File diff suppressed because it is too large. Load diff
+97 -4
View File
@@ -18,11 +18,13 @@ import {
savePiece, savePiece,
savePieceAsTemplate, savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceDeleted,
setPieceFavorite, setPieceFavorite,
uploadPieceImage, uploadPieceImage,
updateTaxonomy,
type PieceInput type PieceInput
} from './content.js'; } from './content.js';
import { closeDatabase } from './database.js'; import { closeDatabase, getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
let directory: string; let directory: string;
@@ -198,8 +200,17 @@ describe('content management', () => {
}); });
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => { it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
createTaxonomy('topic', 'Technikgeschichte'); createTaxonomy('topic', 'Technikgeschichte', 'microchip');
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!; const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
expect(topic.iconName).toBe('microchip');
updateTaxonomy('topic', topic.id, 'Computergeschichte', 'microchip');
expect(getTaxonomy().topics.find((entry) => entry.id === topic.id)).toMatchObject({
name: 'Computergeschichte',
iconName: 'microchip'
});
expect(() => updateTaxonomy('topic', topic.id, 'Computergeschichte', '../unsafe')).toThrow(
'INVALID_ICON'
);
const sourceId = createPiece('find'); const sourceId = createPiece('find');
const source = getPiece(sourceId)!; const source = getPiece(sourceId)!;
savePiece( savePiece(
@@ -253,15 +264,17 @@ describe('content management', () => {
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
); );
await uploadPieceImage( const uploaded = await uploadPieceImage(
id, id,
1, 1,
new File([png], '../bild.png', { type: 'text/html' }), new File([png], '../bild.png', { type: 'text/html' }),
'Ein heller Pixel' 'Ein heller Pixel',
true
); );
const piece = getPiece(id)!; const piece = getPiece(id)!;
expect(piece.media).toHaveLength(1); expect(piece.media).toHaveLength(1);
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' }); expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
expect(piece.presentationMediaId).toBe(uploaded.id);
const media = await getMediaFile(piece.media[0].id); const media = await getMediaFile(piece.media[0].id);
expect(media?.data.equals(png)).toBe(true); expect(media?.data.equals(png)).toBe(true);
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false); expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
@@ -274,7 +287,87 @@ describe('content management', () => {
'' ''
) )
).rejects.toThrow('INVALID_IMAGE_TYPE'); ).rejects.toThrow('INVALID_IMAGE_TYPE');
const stored = getDatabase()
.prepare('SELECT storage_name FROM piece_media WHERE id = ?')
.get(piece.media[0].id) as { storage_name: string };
await deletePieceImage(id, piece.media[0].id, piece.revision); await deletePieceImage(id, piece.media[0].id, piece.revision);
expect(getPiece(id)?.media).toHaveLength(0); expect(getPiece(id)?.media).toHaveLength(0);
expect(getPiece(id)?.presentationMediaId).toBeNull();
expect(await getMediaFile(piece.media[0].id)).toBeNull();
expect(existsSync(path.join(directory, 'uploads', stored.storage_name))).toBe(true);
});
it('selects presentation media atomically, copies active media and omits media from templates', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const selected = await uploadPieceImage(
id,
1,
new File([png], 'auswahl.png', { type: 'image/png' }),
'Auswahl',
true
);
const second = await uploadPieceImage(
id,
selected.revision,
new File([png], 'entfernt.png', { type: 'image/png' }),
'Entfernt'
);
await deletePieceImage(id, second.id, second.revision);
const source = getPiece(id)!;
expect(() =>
savePiece(id, source.revision, {
...source,
title: 'Darf nicht gespeichert werden',
status: 'preparing',
presentationMediaId: 'fremdes-medium'
})
).toThrow('presentation media does not belong to piece');
expect(getPiece(id)).toMatchObject({
title: 'Neues Piece',
revision: source.revision,
presentationMediaId: selected.id
});
const duplicateId = await duplicatePiece(id);
const duplicate = getPiece(duplicateId)!;
expect(duplicate.media).toHaveLength(1);
expect(duplicate.presentationMediaId).toBe(duplicate.media[0].id);
expect(duplicate.presentationMediaId).not.toBe(selected.id);
const template = savePieceAsTemplate(id, 'Ohne Medienreferenz', 'Nur Inhaltsdaten');
const snapshot = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
.get(template.id) as { snapshot_json: string };
expect(JSON.parse(snapshot.snapshot_json)).not.toHaveProperty('presentationMediaId');
expect(getPiece(createPiece(template.id))?.presentationMediaId).toBeNull();
});
it('soft-deletes pieces behind revisions and keeps their original media private', async () => {
const id = createPiece();
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
const uploaded = await uploadPieceImage(
id,
1,
new File([png], 'privat.png', { type: 'image/png' }),
'Privat'
);
const deleted = setPieceDeleted(id, uploaded.revision, true);
expect(getPiece(id)).toBeNull();
expect(listPieces({ statuses: ['preparing'] }).some((piece) => piece.id === id)).toBe(false);
expect(await getMediaFile(uploaded.id)).toBeNull();
expect(() => setPieceFavorite(id, true)).toThrow('NOT_FOUND');
expect(() => savePiece(id, deleted.revision, completeInput())).toThrow('NOT_FOUND');
expect(() => setPieceDeleted(id, uploaded.revision, false)).toThrow('CONFLICT');
const restored = setPieceDeleted(id, deleted.revision, false);
expect(getPiece(id)).toMatchObject({ id, revision: restored.revision });
expect((await getMediaFile(uploaded.id))?.data.equals(png)).toBe(true);
}); });
}); });
+192 -71
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { getServerPaths } from './config.js'; import { getServerPaths } from './config.js';
import { import {
@@ -31,6 +32,7 @@ export type PieceTool = PieceToolInput & {
id: string; id: string;
name: string; name: string;
startUrl: string; startUrl: string;
iconName: string;
isActive: boolean; isActive: boolean;
}; };
@@ -58,6 +60,7 @@ export type PieceInput = {
presentationDescriptionManual: boolean; presentationDescriptionManual: boolean;
presentationImageManual: boolean; presentationImageManual: boolean;
presentationMetadataFetchedAtUtc: string | null; presentationMetadataFetchedAtUtc: string | null;
presentationMediaId?: string | null;
topicIds: string[]; topicIds: string[];
categoryIds: string[]; categoryIds: string[];
sources: PieceSourceInput[]; sources: PieceSourceInput[];
@@ -74,19 +77,20 @@ export type PieceListItem = {
formatId: string | null; formatId: string | null;
format: string; format: string;
categories: { id: string; name: string; color: string }[]; categories: { id: string; name: string; color: string }[];
topics: { id: string; name: string }[]; topics: { id: string; name: string; iconName: string }[];
sourceCount: number; sourceCount: number;
mediaId: string | null; mediaId: string | null;
isFavorite: boolean; isFavorite: boolean;
}; };
export type PieceEditor = Omit<PieceInput, 'status' | 'tools'> & { export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
id: string; id: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
media: PieceMedia[]; media: PieceMedia[];
presentationMediaId: string | null;
isFavorite: boolean; isFavorite: boolean;
tools: PieceTool[]; tools: PieceTool[];
}; };
@@ -103,7 +107,7 @@ export type PieceMedia = {
export type Taxonomy = { export type Taxonomy = {
categories: { id: string; name: string; color: string }[]; categories: { id: string; name: string; color: string }[];
formats: { id: string; name: string }[]; formats: { id: string; name: string }[];
topics: { id: string; name: string }[]; topics: { id: string; name: string; iconName: string }[];
}; };
export type PieceTemplate = { export type PieceTemplate = {
@@ -111,6 +115,7 @@ export type PieceTemplate = {
name: string; name: string;
description: string; description: string;
revision: number; revision: number;
iconName: string;
}; };
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
@@ -131,6 +136,13 @@ function uniqueIds(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
function cleanIconName(value: unknown): string {
const iconName = cleanText(value, 80, 'icon');
if (!iconName) return '';
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
}
function validUrl(value: string): boolean { function validUrl(value: string): boolean {
try { try {
const url = new URL(value); const url = new URL(value);
@@ -226,6 +238,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
} }
const detected = const detected =
sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url); sourcePosition === null ? null : detectVideoProvider(sources[sourcePosition].url);
const presentationMediaId =
input.presentationMediaId === undefined
? undefined
: cleanText(input.presentationMediaId, 100, 'presentation_media') || null;
return { return {
title, title,
outputTitle: cleanText(input.outputTitle, 300, 'output_title'), outputTitle: cleanText(input.outputTitle, 300, 'output_title'),
@@ -254,6 +270,7 @@ function validatePieceInput(input: PieceInput): PieceInput {
presentationDescriptionManual: Boolean(input.presentationDescriptionManual), presentationDescriptionManual: Boolean(input.presentationDescriptionManual),
presentationImageManual: Boolean(input.presentationImageManual), presentationImageManual: Boolean(input.presentationImageManual),
presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null, presentationMetadataFetchedAtUtc: input.presentationMetadataFetchedAtUtc || null,
presentationMediaId,
topicIds, topicIds,
categoryIds, categoryIds,
sources, sources,
@@ -271,7 +288,7 @@ export function getTaxonomy(): Taxonomy {
.prepare('SELECT id, name FROM formats ORDER BY name') .prepare('SELECT id, name FROM formats ORDER BY name')
.all() as Taxonomy['formats'], .all() as Taxonomy['formats'],
topics: database topics: database
.prepare('SELECT id, name FROM topics ORDER BY name') .prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as Taxonomy['topics'] .all() as Taxonomy['topics']
}; };
} }
@@ -297,9 +314,13 @@ export function listPieces(filters: {
formatId?: string; formatId?: string;
topicId?: string; topicId?: string;
categoryId?: string; categoryId?: string;
deleted?: boolean;
}): PieceListItem[] { }): PieceListItem[] {
if (!filters.statuses.length) return []; if (!filters.statuses.length) return [];
const conditions = [`p.status IN (${filters.statuses.map(() => '?').join(', ')})`]; const conditions = [
`p.status IN (${filters.statuses.map(() => '?').join(', ')})`,
filters.deleted ? 'p.deleted_at_utc IS NOT NULL' : 'p.deleted_at_utc IS NULL'
];
const parameters: unknown[] = [...filters.statuses]; const parameters: unknown[] = [...filters.statuses];
const query = filters.query?.trim().toLowerCase(); const query = filters.query?.trim().toLowerCase();
if (query) { if (query) {
@@ -336,7 +357,7 @@ export function listPieces(filters: {
`SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite, `SELECT p.id, p.title, p.summary, p.status, p.revision, p.duration_seconds, p.is_favorite,
p.format_id, COALESCE(f.name, 'Ohne Format') AS format, 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 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 (SELECT pm.id FROM piece_media pm WHERE pm.piece_id = p.id AND pm.removed_at_utc IS NULL ORDER BY pm.position LIMIT 1) AS media_id
FROM pieces p LEFT JOIN formats f ON f.id = p.format_id FROM pieces p LEFT JOIN formats f ON f.id = p.format_id
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
ORDER BY p.is_favorite DESC, ORDER BY p.is_favorite DESC,
@@ -350,7 +371,7 @@ export function listPieces(filters: {
WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name` WHERE pc.piece_id = ? ORDER BY c.sort_order, c.name`
); );
const topicStatement = database.prepare( const topicStatement = database.prepare(
`SELECT t.id, t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id `SELECT t.id, t.name, t.icon_name AS iconName FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name` WHERE pt.piece_id = ? ORDER BY t.name`
); );
return rows.map((row) => ({ return rows.map((row) => ({
@@ -399,6 +420,7 @@ type PieceRow = {
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
is_favorite: number; is_favorite: number;
presentation_media_id: string | null;
}; };
export function getPiece(id: string): PieceEditor | null { export function getPiece(id: string): PieceEditor | null {
@@ -411,8 +433,11 @@ export function getPiece(id: string): PieceEditor | null {
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_provider_id, presentation_title_manual, presentation_description_manual,
presentation_image_manual, presentation_metadata_fetched_at_utc, presentation_image_manual, presentation_metadata_fetched_at_utc,
(SELECT ppm.media_id FROM piece_presentation_media ppm
JOIN piece_media pm ON pm.id = ppm.media_id AND pm.removed_at_utc IS NULL
WHERE ppm.piece_id = pieces.id) AS presentation_media_id,
revision, created_at_utc, updated_at_utc, is_favorite revision, created_at_utc, updated_at_utc, is_favorite
FROM pieces WHERE id = ?` FROM pieces WHERE id = ? AND deleted_at_utc IS NULL`
) )
.get(id) as PieceRow | undefined; .get(id) as PieceRow | undefined;
if (!row) return null; if (!row) return null;
@@ -441,6 +466,7 @@ export function getPiece(id: string): PieceEditor | null {
presentationDescriptionManual: row.presentation_description_manual === 1, presentationDescriptionManual: row.presentation_description_manual === 1,
presentationImageManual: row.presentation_image_manual === 1, presentationImageManual: row.presentation_image_manual === 1,
presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc, presentationMetadataFetchedAtUtc: row.presentation_metadata_fetched_at_utc,
presentationMediaId: row.presentation_media_id,
revision: row.revision, revision: row.revision,
createdAtUtc: row.created_at_utc, createdAtUtc: row.created_at_utc,
updatedAtUtc: row.updated_at_utc, updatedAtUtc: row.updated_at_utc,
@@ -470,7 +496,7 @@ export function getPiece(id: string): PieceEditor | null {
database database
.prepare( .prepare(
`SELECT id, original_name, mime_type, byte_size, alt_text, position `SELECT id, original_name, mime_type, byte_size, alt_text, position
FROM piece_media WHERE piece_id = ? ORDER BY position` FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL ORDER BY position`
) )
.all(id) as { .all(id) as {
id: string; id: string;
@@ -491,7 +517,7 @@ export function getPiece(id: string): PieceEditor | null {
tools: ( tools: (
database database
.prepare( .prepare(
`SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.is_active `SELECT pt.id, pt.tool_id, pt.work_url, pt.note, t.name, t.start_url, t.icon_name, t.is_active
FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id FROM piece_tools pt JOIN tools t ON t.id = pt.tool_id
WHERE pt.piece_id = ? ORDER BY pt.position` WHERE pt.piece_id = ? ORDER BY pt.position`
) )
@@ -502,6 +528,7 @@ export function getPiece(id: string): PieceEditor | null {
note: string; note: string;
name: string; name: string;
start_url: string; start_url: string;
icon_name: string;
is_active: number; is_active: number;
}[] }[]
).map((tool) => ({ ).map((tool) => ({
@@ -511,6 +538,7 @@ export function getPiece(id: string): PieceEditor | null {
note: tool.note, note: tool.note,
name: tool.name, name: tool.name,
startUrl: tool.start_url, startUrl: tool.start_url,
iconName: tool.icon_name,
isActive: tool.is_active === 1 isActive: tool.is_active === 1
})) }))
}; };
@@ -544,9 +572,20 @@ function replaceRelations(pieceId: string, input: PieceInput) {
); );
} }
function replacePresentationMedia(pieceId: string, mediaId: string | null | undefined) {
if (mediaId === undefined) return;
const database = getDatabase();
database.prepare('DELETE FROM piece_presentation_media WHERE piece_id = ?').run(pieceId);
if (mediaId) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(pieceId, mediaId);
}
}
export function setPieceFavorite(id: string, favorite: boolean) { export function setPieceFavorite(id: string, favorite: boolean) {
const result = getDatabase() const result = getDatabase()
.prepare('UPDATE pieces SET is_favorite = ? WHERE id = ?') .prepare('UPDATE pieces SET is_favorite = ? WHERE id = ? AND deleted_at_utc IS NULL')
.run(favorite ? 1 : 0, id); .run(favorite ? 1 : 0, id);
if (result.changes !== 1) throw new Error('NOT_FOUND'); if (result.changes !== 1) throw new Error('NOT_FOUND');
} }
@@ -568,7 +607,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
presentation_description_manual = ?, presentation_image_manual = ?, presentation_description_manual = ?, presentation_image_manual = ?,
presentation_metadata_fetched_at_utc = ?, presentation_metadata_fetched_at_utc = ?,
updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL updated_at_utc = ?, revision = revision + 1, archived_from_status = NULL
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run( .run(
input.title, input.title,
@@ -599,10 +638,13 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
expectedRevision expectedRevision
); );
if (result.changes !== 1) { if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id); const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
} }
replaceRelations(id, input); replaceRelations(id, input);
replacePresentationMedia(id, input.presentationMediaId);
}); });
save(); save();
return { revision: expectedRevision + 1, updatedAtUtc }; return { revision: expectedRevision + 1, updatedAtUtc };
@@ -633,6 +675,7 @@ function blankPieceInput(): PieceInput {
presentationDescriptionManual: false, presentationDescriptionManual: false,
presentationImageManual: false, presentationImageManual: false,
presentationMetadataFetchedAtUtc: null, presentationMetadataFetchedAtUtc: null,
presentationMediaId: null,
topicIds: [], topicIds: [],
categoryIds: [], categoryIds: [],
sources: [] sources: []
@@ -647,6 +690,7 @@ function templateInput(templateId: string): PieceInput {
if (!row) throw new Error('INVALID_TEMPLATE'); if (!row) throw new Error('INVALID_TEMPLATE');
try { try {
const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>; const snapshot = JSON.parse(row.snapshot_json) as Partial<PieceInput>;
delete snapshot.presentationMediaId;
return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' }); return validatePieceInput({ ...blankPieceInput(), ...snapshot, status: 'preparing' });
} catch { } catch {
throw new Error('INVALID_TEMPLATE'); throw new Error('INVALID_TEMPLATE');
@@ -654,22 +698,33 @@ function templateInput(templateId: string): PieceInput {
} }
export function listPieceTemplates(): PieceTemplate[] { export function listPieceTemplates(): PieceTemplate[] {
const rows = getDatabase() const database = getDatabase();
const rows = database
.prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name') .prepare('SELECT id, name, description, revision FROM piece_templates ORDER BY name')
.all() as PieceTemplate[]; .all() as Omit<PieceTemplate, 'iconName'>[];
const iconForTemplate = (templateId: string) => {
const input = templateInput(templateId);
if (!input.topicIds.length) return 'newspaper';
return (
(
database.prepare('SELECT icon_name FROM topics WHERE id = ?').get(input.topicIds[0]) as
{ icon_name: string } | undefined
)?.icon_name || 'newspaper'
);
};
return [ return [
{ {
id: 'blank', id: 'blank',
name: 'Leer starten', name: 'Leer starten',
description: 'Ein freies Piece ohne Vorgaben.', description: 'Ein freies Piece ohne Vorgaben.',
revision: 0 revision: 0,
iconName: 'newspaper'
}, },
...rows.filter((template) => { ...rows.flatMap((template) => {
try { try {
templateInput(template.id); return [{ ...template, iconName: iconForTemplate(template.id) }];
return true;
} catch { } catch {
return false; return [];
} }
}) })
]; ];
@@ -726,6 +781,7 @@ export function savePieceAsTemplate(
delete snapshot.createdAtUtc; delete snapshot.createdAtUtc;
delete snapshot.updatedAtUtc; delete snapshot.updatedAtUtc;
delete snapshot.media; delete snapshot.media;
delete snapshot.presentationMediaId;
snapshot.status = 'preparing'; snapshot.status = 'preparing';
const snapshotJson = JSON.stringify(snapshot); const snapshotJson = JSON.stringify(snapshot);
const database = getDatabase(); const database = getDatabase();
@@ -787,18 +843,22 @@ export function setPieceArchived(id: string, expectedRevision: number, archived:
? database ? database
.prepare( .prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1, `UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'` updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'archived'
AND deleted_at_utc IS NULL`
) )
.run(now, id, expectedRevision) .run(now, id, expectedRevision)
: database : database
.prepare( .prepare(
`UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL, `UPDATE pieces SET status = COALESCE(archived_from_status, 'preparing'), archived_from_status = NULL,
revision = revision + 1, updated_at_utc = ? revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status = 'archived'` WHERE id = ? AND revision = ? AND status = 'archived'
AND deleted_at_utc IS NULL`
) )
.run(now, id, expectedRevision); .run(now, id, expectedRevision);
if (result.changes !== 1) { if (result.changes !== 1) {
const exists = database.prepare('SELECT 1 FROM pieces WHERE id = ?').get(id); const exists = database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(id);
throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND'); throw new Error(exists ? 'CONFLICT' : 'NOT_FOUND');
} }
} }
@@ -812,7 +872,11 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 }); await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
const database = getDatabase(); const database = getDatabase();
const storedMedia = database const storedMedia = database
.prepare('SELECT * FROM piece_media WHERE piece_id = ? ORDER BY position') .prepare(
`SELECT pm.*, ppm.media_id IS NOT NULL AS is_presentation
FROM piece_media pm LEFT JOIN piece_presentation_media ppm ON ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(sourceId) as { .all(sourceId) as {
id: string; id: string;
storage_name: string; storage_name: string;
@@ -821,8 +885,9 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
byte_size: number; byte_size: number;
alt_text: string; alt_text: string;
position: number; position: number;
is_presentation: number;
}[]; }[];
const copied: { source: (typeof storedMedia)[number]; storageName: string }[] = []; const copied: { id: string; source: (typeof storedMedia)[number]; storageName: string }[] = [];
try { try {
for (const media of storedMedia) { for (const media of storedMedia) {
const extension = ALLOWED_IMAGES.get(media.mime_type)!; const extension = ALLOWED_IMAGES.get(media.mime_type)!;
@@ -831,7 +896,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
path.join(uploadDirectory, media.storage_name), path.join(uploadDirectory, media.storage_name),
path.join(uploadDirectory, storageName) path.join(uploadDirectory, storageName)
); );
copied.push({ source: media, storageName }); copied.push({ id: randomUUID(), source: media, storageName });
} }
database.transaction(() => { database.transaction(() => {
database database
@@ -879,7 +944,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
); );
for (const media of copied) { for (const media of copied) {
addMedia.run( addMedia.run(
randomUUID(), media.id,
id, id,
media.storageName, media.storageName,
media.source.original_name, media.source.original_name,
@@ -890,6 +955,12 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
now now
); );
} }
const presentation = copied.find((media) => media.source.is_presentation === 1);
if (presentation) {
database
.prepare('INSERT INTO piece_presentation_media (piece_id, media_id) VALUES (?, ?)')
.run(id, presentation.id);
}
})(); })();
return id; return id;
} catch (error) { } catch (error) {
@@ -916,7 +987,7 @@ export async function loadPiecePresentationMetadata(
p.presentation_image_manual, s.url p.presentation_image_manual, s.url
FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id FROM pieces p LEFT JOIN piece_sources s ON s.piece_id = p.id
AND s.position = p.presentation_source_position AND s.position = p.presentation_source_position
WHERE p.id = ? AND p.status != 'archived'` WHERE p.id = ? AND p.status != 'archived' AND p.deleted_at_utc IS NULL`
) )
.get(id) as .get(id) as
| { | {
@@ -942,7 +1013,7 @@ export async function loadPiecePresentationMetadata(
presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END, presentation_image_url = CASE WHEN presentation_image_manual = 1 THEN presentation_image_url ELSE ? END,
presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_site_name = ?, presentation_provider = ?, presentation_provider_id = ?,
presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1 presentation_metadata_fetched_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run( .run(
metadata.title, metadata.title,
@@ -964,7 +1035,8 @@ export async function uploadPieceImage(
pieceId: string, pieceId: string,
expectedRevision: number, expectedRevision: number,
file: File, file: File,
altTextValue: string altTextValue: string,
selectForPresentation = false
) { ) {
if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE'); if (!file.size || file.size > MAX_UPLOAD_BYTES) throw new Error('INVALID_IMAGE_SIZE');
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -978,85 +1050,86 @@ export async function uploadPieceImage(
const filePath = path.join(uploadDirectory, storageName); const filePath = path.join(uploadDirectory, storageName);
await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 }); await writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
const database = getDatabase(); const database = getDatabase();
const mediaId = randomUUID();
const now = new Date().toISOString();
try { try {
database.transaction(() => { database.transaction(() => {
const updated = database const updated = database
.prepare( .prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ? `UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run(new Date().toISOString(), pieceId, expectedRevision); .run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT'); if (updated.changes !== 1) throw new Error('CONFLICT');
const count = database const count = database
.prepare('SELECT count(*) AS count FROM piece_media WHERE piece_id = ?') .prepare(
`SELECT count(*) AS count
FROM piece_media WHERE piece_id = ? AND removed_at_utc IS NULL`
)
.get(pieceId) as { count: number }; .get(pieceId) as { count: number };
if (count.count >= 8) throw new Error('TOO_MANY_IMAGES'); if (count.count >= 8) throw new Error('TOO_MANY_IMAGES');
const position = (
database
.prepare(
'SELECT COALESCE(max(position), -1) + 1 AS next FROM piece_media WHERE piece_id = ?'
)
.get(pieceId) as { next: number }
).next;
database database
.prepare( .prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type, `INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
randomUUID(), mediaId,
pieceId, pieceId,
storageName, storageName,
path.basename(file.name).slice(0, 200) || `bild.${extension}`, path.basename(file.name).slice(0, 200) || `bild.${extension}`,
detected.mime, detected.mime,
buffer.byteLength, buffer.byteLength,
altText, altText,
count.count, position,
new Date().toISOString() now
); );
if (selectForPresentation) replacePresentationMedia(pieceId, mediaId);
})(); })();
} catch (error) { } catch (error) {
await unlink(filePath).catch(() => {}); await unlink(filePath).catch(() => {});
throw error; throw error;
} }
return { id: mediaId, revision: expectedRevision + 1, updatedAtUtc: now };
} }
export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) { export async function deletePieceImage(pieceId: string, mediaId: string, expectedRevision: number) {
const database = getDatabase(); const database = getDatabase();
const media = database const now = new Date().toISOString();
.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(() => { database.transaction(() => {
const updated = database const updated = database
.prepare( .prepare(
`UPDATE pieces SET revision = revision + 1, updated_at_utc = ? `UPDATE pieces SET revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND revision = ? AND status != 'archived'` WHERE id = ? AND revision = ? AND status != 'archived' AND deleted_at_utc IS NULL`
) )
.run(new Date().toISOString(), pieceId, expectedRevision); .run(now, pieceId, expectedRevision);
if (updated.changes !== 1) throw new Error('CONFLICT'); if (updated.changes !== 1) throw new Error('CONFLICT');
database.prepare('DELETE FROM piece_media WHERE id = ?').run(mediaId); const removed = database
.prepare(
`UPDATE piece_media SET removed_at_utc = ?
WHERE id = ? AND piece_id = ? AND removed_at_utc IS NULL`
)
.run(now, mediaId, pieceId);
if (removed.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM piece_presentation_media WHERE media_id = ?').run(mediaId);
})(); })();
} catch (error) { return { revision: expectedRevision + 1, updatedAtUtc: now };
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) { export async function getMediaFile(id: string) {
const row = getDatabase() const row = getDatabase()
.prepare('SELECT storage_name, mime_type, byte_size, alt_text FROM piece_media WHERE id = ?') .prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, pm.alt_text
FROM piece_media pm JOIN pieces p ON p.id = pm.piece_id
WHERE pm.id = ? AND pm.removed_at_utc IS NULL AND p.deleted_at_utc IS NULL`
)
.get(id) as .get(id) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined; { storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!row) return null; if (!row) return null;
@@ -1068,7 +1141,12 @@ export async function getMediaFile(id: string) {
}; };
} }
export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue?: string) { export function createTaxonomy(
kind: TaxonomyKind,
nameValue: string,
colorOrIconValue?: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy'); const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY'); if (!name) throw new Error('INVALID_TAXONOMY');
const database = getDatabase(); const database = getDatabase();
@@ -1081,7 +1159,7 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
if (duplicate) throw new Error('TAXONOMY_EXISTS'); if (duplicate) throw new Error('TAXONOMY_EXISTS');
const id = randomUUID(); const id = randomUUID();
if (kind === 'category') { if (kind === 'category') {
const color = String(colorValue ?? ''); const color = String(colorOrIconValue ?? '');
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR'); if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error('INVALID_COLOR');
const order = database const order = database
.prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories') .prepare('SELECT COALESCE(max(sort_order), -1) + 1 AS next FROM section_categories')
@@ -1091,10 +1169,53 @@ export function createTaxonomy(kind: TaxonomyKind, nameValue: string, colorValue
database database
.prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO section_categories (id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(id, name, color, order.next); .run(id, name, color, order.next);
} else if (kind === 'topic') {
database
.prepare('INSERT INTO topics (id, name, icon_name) VALUES (?, ?, ?)')
.run(id, name, cleanIconName(iconNameValue ?? colorOrIconValue));
} else { } else {
const table = kind === 'format' ? 'formats' : 'topics'; database.prepare('INSERT INTO formats (id, name) VALUES (?, ?)').run(id, name);
database.prepare(`INSERT INTO ${table} (id, name) VALUES (?, ?)`).run(id, name);
} }
return id;
}
export function updateTaxonomy(
kind: TaxonomyKind,
id: string,
nameValue: string,
iconNameValue?: string
) {
const name = cleanText(nameValue, 80, 'taxonomy');
if (!name) throw new Error('INVALID_TAXONOMY');
const table =
kind === 'category' ? 'section_categories' : kind === 'format' ? 'formats' : 'topics';
const database = getDatabase();
const duplicate = database
.prepare(`SELECT 1 FROM ${table} WHERE lower(name) = lower(?) AND id != ?`)
.get(name, id);
if (duplicate) throw new Error('TAXONOMY_EXISTS');
const result =
kind === 'topic'
? database
.prepare('UPDATE topics SET name = ?, icon_name = ? WHERE id = ?')
.run(name, cleanIconName(iconNameValue), id)
: database.prepare(`UPDATE ${table} SET name = ? WHERE id = ?`).run(name, id);
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function setPieceDeleted(id: string, expectedRevision: number, deleted: boolean) {
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
throw new Error('INVALID_REVISION');
}
const now = new Date().toISOString();
const result = getDatabase()
.prepare(
`UPDATE pieces SET deleted_at_utc = ?, updated_at_utc = ?, revision = revision + 1
WHERE id = ? AND revision = ? AND deleted_at_utc IS ${deleted ? 'NULL' : 'NOT NULL'}`
)
.run(deleted ? now : null, now, id, expectedRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
return { revision: expectedRevision + 1, updatedAtUtc: now };
} }
export function deleteTaxonomy(kind: TaxonomyKind, id: string) { export function deleteTaxonomy(kind: TaxonomyKind, id: string) {
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 9, migrations: 11,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+39 -6
View File
@@ -1,5 +1,8 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js'; import { buildEmbedUrl, fetchPresentationImage, type PresentationContent } from './presentation.js';
@@ -726,7 +729,7 @@ function getPresentationViewForShow(showId: string): PresentationView {
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null }; if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
const usage = database const usage = database
.prepare( .prepare(
`SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot, u.original_piece_id, `SELECT u.title_snapshot, u.output_title_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -740,7 +743,6 @@ function getPresentationViewForShow(showId: string): PresentationView {
title_snapshot: string; title_snapshot: string;
output_title_snapshot: string; output_title_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
original_piece_id: string | null;
presentation_mode_snapshot: PresentationContent['mode']; presentation_mode_snapshot: PresentationContent['mode'];
presentation_source_label_snapshot: string; presentation_source_label_snapshot: string;
presentation_source_url_snapshot: string; presentation_source_url_snapshot: string;
@@ -784,7 +786,14 @@ function getPresentationViewForShow(showId: string): PresentationView {
usage.presentation_source_timecode_snapshot usage.presentation_source_timecode_snapshot
), ),
sources: [], sources: [],
images: [] images: (
database
.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
)
.all(state.presentation_usage_id) as { id: string; alt_text: string }[]
).map((media) => ({ id: media.id, altText: media.alt_text }))
} }
}; };
} }
@@ -801,9 +810,33 @@ export async function getPresentationPreviewImage(token: string) {
export async function getPresentationMedia( export async function getPresentationMedia(
token: string, token: string,
mediaId: string snapshotId: string
): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> { ): Promise<{ data: Buffer; mimeType: string; byteSize: number; altText: string } | null> {
void token; const database = getDatabase();
void mediaId; const showId = presentationShowId(database, token);
if (!showId || !snapshotId) return null;
const media = database
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size, ums.alt_text
FROM show_live_states state
JOIN usage_media_snapshots ums ON ums.usage_id = state.presentation_usage_id
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE state.show_id = ? AND s.show_id = state.show_id
AND s.is_reserve_bank = 0 AND ums.id = ? AND ums.is_presentation = 1`
)
.get(showId, snapshotId) as
{ storage_name: string; mime_type: string; byte_size: number; alt_text: string } | undefined;
if (!media) return null;
try {
return {
data: await readFile(path.join(getServerPaths().uploads, media.storage_name)),
mimeType: media.mime_type,
byteSize: media.byte_size,
altText: media.alt_text
};
} catch {
return null; return null;
} }
}
+5 -2
View File
@@ -86,7 +86,7 @@ export function getPieces(): PreviewPiece[] {
(SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count (SELECT count(*) FROM piece_sources ps WHERE ps.piece_id = p.id) AS source_count
FROM pieces p FROM pieces p
LEFT JOIN formats f ON f.id = p.format_id LEFT JOIN formats f ON f.id = p.format_id
WHERE p.status IN ('preparing', 'ready') WHERE p.status IN ('preparing', 'ready') AND p.deleted_at_utc IS NULL
ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title` ORDER BY CASE p.status WHEN 'ready' THEN 0 ELSE 1 END, p.title`
) )
.all() as PieceRow[]; .all() as PieceRow[];
@@ -175,9 +175,12 @@ export function getTaxonomy() {
id: string; id: string;
name: string; name: string;
}[], }[],
topics: database.prepare('SELECT id, name FROM topics ORDER BY name').all() as { topics: database
.prepare('SELECT id, name, icon_name AS iconName FROM topics ORDER BY name')
.all() as {
id: string; id: string;
name: string; name: string;
iconName: string;
}[] }[]
}; };
} }
+112 -3
View File
@@ -59,6 +59,7 @@ function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
presentationDescription: edited.presentationDescription, presentationDescription: edited.presentationDescription,
presentationImageUrl: edited.presentationImageUrl, presentationImageUrl: edited.presentationImageUrl,
presentationSiteName: edited.presentationSiteName, presentationSiteName: edited.presentationSiteName,
presentationMediaId: edited.presentationMediaId,
ownNotes: edited.ownNotes, ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds durationSeconds: edited.durationSeconds
}; };
@@ -492,7 +493,7 @@ describe('show builder data layer', () => {
expect(usage).toMatchObject({ expect(usage).toMatchObject({
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }], sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }], tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
topics: ['Gaming'] topics: [{ name: 'Gaming', iconName: '' }]
}); });
database database
@@ -518,7 +519,7 @@ describe('show builder data layer', () => {
name: 'Tierlist', name: 'Tierlist',
workUrl: 'https://example.com/board' workUrl: 'https://example.com/board'
}); });
expect(usage.topics).toEqual(['Gaming']); expect(usage.topics).toEqual([{ name: 'Gaming', iconName: '' }]);
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision); refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
usage = getShow(show.id)!.sections[0].usages[0]; usage = getShow(show.id)!.sections[0].usages[0];
@@ -527,7 +528,115 @@ describe('show builder data layer', () => {
name: 'Neuer Katalogname', name: 'Neuer Katalogname',
workUrl: 'https://example.com/new' workUrl: 'https://example.com/new'
}); });
expect(usage.topics).toEqual(['Netzkultur']); expect(usage.topics).toEqual([{ name: 'Netzkultur', iconName: '' }]);
});
it('snapshots active media, their presentation choice and topic icons independently', () => {
const database = getDatabase();
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
const insertMedia = database.prepare(
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
byte_size, alt_text, position, created_at_utc, removed_at_utc)
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
);
insertMedia.run(
'media-first',
'first.png',
'first-original.png',
'Erstes Bild',
0,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-second',
'second.png',
'second-original.png',
'Zweites Bild',
1,
'2026-10-23T00:00:00.000Z',
null
);
insertMedia.run(
'media-removed',
'removed.png',
'removed-original.png',
'Entferntes Bild',
2,
'2026-10-23T00:00:00.000Z',
'2026-10-23T01:00:00.000Z'
);
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
)
.run();
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
const section = addShowSection(show.id, show.revision, {
name: 'Bilder',
categoryId: null,
explicitDurationSeconds: null
});
const added = addPieceUsage(show.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
let usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
{
originalMediaId: 'media-second',
altText: 'Zweites Bild',
position: 1,
isPresentation: false
}
]);
expect(usage.presentationMediaId).toBe(usage.media[0].id);
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
...editedUsage(usage),
presentationMediaId: usage.media[1].id
});
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
expect(() =>
updatePieceUsage(show.id, usage.id, selected.revision, {
...editedUsage(usage),
presentationMediaId: 'media-first'
})
).toThrow('INVALID_PRESENTATION_MEDIA');
expect(getShow(show.id)?.revision).toBe(selected.revision);
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
'media-first',
'media-second'
]);
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
usage.media.map((item) => item.id)
);
database
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
.run('2026-10-23T02:00:00.000Z');
database
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
.run();
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
database
.prepare(
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
)
.run();
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
usage = getShow(show.id)!.sections[0].usages[0];
expect(usage.media).toMatchObject([
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
]);
expect(duplicateUsage.media).toHaveLength(2);
}); });
it('persists normalized section and usage reordering, moving and removal', () => { it('persists normalized section and usage reordering, moving and removal', () => {
+164 -11
View File
@@ -69,7 +69,19 @@ export type PieceUsage = {
workUrl: string; workUrl: string;
note: string; note: string;
}[]; }[];
topics: string[]; topics: UsageTopic[];
media: PieceUsageMedia[];
presentationMediaId: string | null;
};
export type UsageTopic = { name: string; iconName: string };
export type PieceUsageMedia = {
id: string;
originalMediaId: string;
altText: string;
position: number;
isPresentation: boolean;
}; };
export type SentPieceUsage = { export type SentPieceUsage = {
@@ -158,6 +170,7 @@ export type PieceUsageUpdate = {
ownNotes: string; ownNotes: string;
durationSeconds: number; durationSeconds: number;
tools?: { id: string; workUrl: string; note: string }[]; tools?: { id: string; workUrl: string; note: string }[];
presentationMediaId?: string | null;
}; };
export type MovePieceUsageInput = { export type MovePieceUsageInput = {
@@ -363,6 +376,43 @@ function usageSection(
return row; return row;
} }
function requireUsagesRemovable(
database: Database.Database,
showId: string,
usageIds: string[]
): void {
if (!usageIds.length) return;
const placeholders = usageIds.map(() => '?').join(', ');
const state = database
.prepare(
`SELECT current_usage_id, presentation_usage_id FROM show_live_states WHERE show_id = ?`
)
.get(showId) as
{ current_usage_id: string | null; presentation_usage_id: string | null } | undefined;
if (state?.current_usage_id && usageIds.includes(state.current_usage_id)) {
throw new Error('USAGE_IS_CURRENT');
}
if (state?.presentation_usage_id && usageIds.includes(state.presentation_usage_id)) {
throw new Error('USAGE_IS_PRESENTED');
}
if (
database
.prepare(`SELECT 1 FROM live_usage_runs WHERE usage_id IN (${placeholders}) LIMIT 1`)
.get(...usageIds)
) {
throw new Error('USAGE_HAS_LIVE_RUNS');
}
if (
database
.prepare(
`SELECT 1 FROM piece_usages WHERE id IN (${placeholders}) AND status != 'open' LIMIT 1`
)
.get(...usageIds)
) {
throw new Error('USAGE_NOT_OPEN');
}
}
function normalizePositions( function normalizePositions(
database: Database.Database, database: Database.Database,
table: 'show_sections' | 'piece_usages', table: 'show_sections' | 'piece_usages',
@@ -450,9 +500,39 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
})), })),
topics: ( topics: (
database database
.prepare('SELECT name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position') .prepare(
.all(row.id) as { name: string }[] 'SELECT name, icon_name FROM usage_topic_snapshots WHERE usage_id = ? ORDER BY position'
).map((topic) => topic.name) )
.all(row.id) as { name: string; icon_name: string }[]
).map((topic) => ({ name: topic.name, iconName: topic.icon_name })),
media: (
database
.prepare(
`SELECT id, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ? ORDER BY position`
)
.all(row.id) as {
id: string;
original_media_id: string;
alt_text: string;
position: number;
is_presentation: number;
}[]
).map((media) => ({
id: media.id,
originalMediaId: media.original_media_id,
altText: media.alt_text,
position: media.position,
isPresentation: media.is_presentation === 1
})),
presentationMediaId:
(
database
.prepare(
'SELECT id FROM usage_media_snapshots WHERE usage_id = ? AND is_presentation = 1'
)
.get(row.id) as { id: string } | undefined
)?.id ?? null
}; };
} }
@@ -460,6 +540,7 @@ function snapshotPieceRelations(database: Database.Database, usageId: string, pi
database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_source_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_tool_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId); database.prepare('DELETE FROM usage_topic_snapshots WHERE usage_id = ?').run(usageId);
database.prepare('DELETE FROM usage_media_snapshots WHERE usage_id = ?').run(usageId);
const sources = database const sources = database
.prepare( .prepare(
`SELECT label, url, timecode_seconds, position FROM piece_sources `SELECT label, url, timecode_seconds, position FROM piece_sources
@@ -518,14 +599,44 @@ function snapshotPieceRelations(database: Database.Database, usageId: string, pi
} }
const topics = database const topics = database
.prepare( .prepare(
`SELECT t.name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id `SELECT t.name, t.icon_name FROM piece_topics pt JOIN topics t ON t.id = pt.topic_id
WHERE pt.piece_id = ? ORDER BY t.name, t.id` WHERE pt.piece_id = ? ORDER BY t.name, t.id`
) )
.all(pieceId) as { name: string }[]; .all(pieceId) as { name: string; icon_name: string }[];
const addTopic = database.prepare( const addTopic = database.prepare(
'INSERT INTO usage_topic_snapshots (usage_id, name, position) VALUES (?, ?, ?)' 'INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position) VALUES (?, ?, ?, ?)'
); );
topics.forEach((topic, position) => addTopic.run(usageId, topic.name, position)); topics.forEach((topic, position) => addTopic.run(usageId, topic.name, topic.icon_name, position));
const media = database
.prepare(
`SELECT pm.id, pm.alt_text, pm.position,
CASE WHEN ppm.media_id IS NULL THEN 0 ELSE 1 END AS is_presentation
FROM piece_media pm
LEFT JOIN piece_presentation_media ppm
ON ppm.piece_id = pm.piece_id AND ppm.media_id = pm.id
WHERE pm.piece_id = ? AND pm.removed_at_utc IS NULL ORDER BY pm.position`
)
.all(pieceId) as {
id: string;
alt_text: string;
position: number;
is_presentation: number;
}[];
const addMedia = database.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES (?, ?, ?, ?, ?, ?)`
);
for (const item of media) {
addMedia.run(
randomUUID(),
usageId,
item.id,
item.alt_text,
item.position,
item.is_presentation
);
}
} }
function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) { function copyUsageRelations(database: Database.Database, sourceUsageId: string, usageId: string) {
@@ -584,8 +695,16 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
); );
database database
.prepare( .prepare(
`INSERT INTO usage_topic_snapshots (usage_id, name, position) `INSERT INTO usage_topic_snapshots (usage_id, name, icon_name, position)
SELECT ?, name, position FROM usage_topic_snapshots WHERE usage_id = ?` SELECT ?, name, icon_name, position FROM usage_topic_snapshots WHERE usage_id = ?`
)
.run(usageId, sourceUsageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
SELECT lower(hex(randomblob(16))), ?, original_media_id, alt_text, position, is_presentation
FROM usage_media_snapshots WHERE usage_id = ?`
) )
.run(usageId, sourceUsageId); .run(usageId, sourceUsageId);
} }
@@ -949,6 +1068,12 @@ export function removeShowSection(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
const usageIds = (
database.prepare('SELECT id FROM piece_usages WHERE show_section_id = ?').all(sectionId) as {
id: string;
}[]
).map((usage) => usage.id);
requireUsagesRemovable(database, showId, usageIds);
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId); database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
const ids = ( const ids = (
database database
@@ -1034,6 +1159,13 @@ export function addPieceUsage(
} }
| undefined; | undefined;
if (!piece) throw new Error('NOT_FOUND'); if (!piece) throw new Error('NOT_FOUND');
if (
!database
.prepare('SELECT 1 FROM pieces WHERE id = ? AND deleted_at_utc IS NULL')
.get(input.pieceId)
) {
throw new Error('NOT_FOUND');
}
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY'); if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
const ids = ( const ids = (
database database
@@ -1237,7 +1369,7 @@ export function refreshPieceUsageFromOriginal(
FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id FROM piece_usages u JOIN pieces p ON p.id = u.original_piece_id
LEFT JOIN piece_sources ps ON ps.piece_id = p.id LEFT JOIN piece_sources ps ON ps.piece_id = p.id
AND ps.position = p.presentation_source_position AND ps.position = p.presentation_source_position
WHERE u.id = ?` WHERE u.id = ? AND p.deleted_at_utc IS NULL`
) )
.get(usageId) as .get(usageId) as
| { | {
@@ -1400,6 +1532,26 @@ export function updatePieceUsage(
updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId); updateTool.run(workUrl, cleanText(tool.note, 2_000, 'tools'), tool.id, usageId);
} }
} }
if (input.presentationMediaId !== undefined) {
if (
input.presentationMediaId !== null &&
!database
.prepare('SELECT 1 FROM usage_media_snapshots WHERE id = ? AND usage_id = ?')
.get(input.presentationMediaId, usageId)
) {
throw new Error('INVALID_PRESENTATION_MEDIA');
}
database
.prepare('UPDATE usage_media_snapshots SET is_presentation = 0 WHERE usage_id = ?')
.run(usageId);
if (input.presentationMediaId !== null) {
database
.prepare(
'UPDATE usage_media_snapshots SET is_presentation = 1 WHERE id = ? AND usage_id = ?'
)
.run(input.presentationMediaId, usageId);
}
}
return result; return result;
})(); })();
} }
@@ -1413,6 +1565,7 @@ export function removePieceUsage(
return database.transaction(() => { return database.transaction(() => {
const result = bumpShow(database, showId, expectedRevision); const result = bumpShow(database, showId, expectedRevision);
const source = usageSection(database, showId, usageId); const source = usageSection(database, showId, usageId);
requireUsagesRemovable(database, showId, [usageId]);
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId); database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
const ids = ( const ids = (
database database
+15 -1
View File
@@ -29,6 +29,7 @@ describe('tool catalog', () => {
name: 'Tierlist', name: 'Tierlist',
startUrl: 'https://example.com/tierlist', startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking', description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true, isActive: true,
formatIds: ['format-quiz'] formatIds: ['format-quiz']
}); });
@@ -38,6 +39,7 @@ describe('tool catalog', () => {
name: 'Tierlist', name: 'Tierlist',
startUrl: 'https://example.com/tierlist', startUrl: 'https://example.com/tierlist',
description: 'Gemeinsames Ranking', description: 'Gemeinsames Ranking',
iconName: 'ranking-star',
isActive: true, isActive: true,
formatIds: ['format-quiz'] formatIds: ['format-quiz']
} }
@@ -46,19 +48,31 @@ describe('tool catalog', () => {
name: 'Tierlist', name: 'Tierlist',
startUrl: '', startUrl: '',
description: '', description: '',
iconName: 'list-ol',
isActive: false, isActive: false,
formatIds: [] formatIds: []
}); });
expect(listTools()).toEqual([]); expect(listTools()).toEqual([]);
expect(listTools(true)[0]).toMatchObject({ id, isActive: false }); expect(listTools(true)[0]).toMatchObject({ id, iconName: 'list-ol', isActive: false });
expect(() => expect(() =>
createTool({ createTool({
name: 'Unsicher', name: 'Unsicher',
startUrl: 'javascript:alert(1)', startUrl: 'javascript:alert(1)',
description: '', description: '',
iconName: 'wrench',
isActive: true, isActive: true,
formatIds: [] formatIds: []
}) })
).toThrow('INVALID_TOOL_URL'); ).toThrow('INVALID_TOOL_URL');
expect(() =>
createTool({
name: 'Unbekanntes Icon',
startUrl: '',
description: '',
iconName: 'kein-font-awesome-icon',
isActive: true,
formatIds: []
})
).toThrow('INVALID_ICON');
}); });
}); });
+24 -5
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { classicRegularIconNames } from '#lib/icons/classic-regular.js';
import { getDatabase } from './database.js'; import { getDatabase } from './database.js';
export type Tool = { export type Tool = {
@@ -6,6 +7,7 @@ export type Tool = {
name: string; name: string;
startUrl: string; startUrl: string;
description: string; description: string;
iconName: string;
isActive: boolean; isActive: boolean;
formatIds: string[]; formatIds: string[];
}; };
@@ -44,6 +46,11 @@ function validated(input: ToolInput): ToolInput {
name: clean(input.name, 120, true), name: clean(input.name, 120, true),
startUrl: validUrl(clean(input.startUrl, 2_000)), startUrl: validUrl(clean(input.startUrl, 2_000)),
description: clean(input.description, 2_000), description: clean(input.description, 2_000),
iconName: (() => {
const iconName = clean(input.iconName, 120, true);
if (!classicRegularIconNames.has(iconName)) throw new Error('INVALID_ICON');
return iconName;
})(),
isActive: Boolean(input.isActive), isActive: Boolean(input.isActive),
formatIds formatIds
}; };
@@ -53,7 +60,7 @@ export function listTools(includeInactive = false): Tool[] {
const database = getDatabase(); const database = getDatabase();
const rows = database const rows = database
.prepare( .prepare(
`SELECT id, name, start_url, description, is_active FROM tools `SELECT id, name, start_url, description, icon_name, is_active FROM tools
${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name` ${includeInactive ? '' : 'WHERE is_active = 1'} ORDER BY is_active DESC, name`
) )
.all() as { .all() as {
@@ -61,6 +68,7 @@ export function listTools(includeInactive = false): Tool[] {
name: string; name: string;
start_url: string; start_url: string;
description: string; description: string;
icon_name: string;
is_active: number; is_active: number;
}[]; }[];
const formats = database.prepare( const formats = database.prepare(
@@ -71,6 +79,7 @@ export function listTools(includeInactive = false): Tool[] {
name: row.name, name: row.name,
startUrl: row.start_url, startUrl: row.start_url,
description: row.description, description: row.description,
iconName: row.icon_name,
isActive: row.is_active === 1, isActive: row.is_active === 1,
formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id) formatIds: (formats.all(row.id) as { format_id: string }[]).map((item) => item.format_id)
})); }));
@@ -92,10 +101,19 @@ export function createTool(rawInput: ToolInput) {
database.transaction(() => { database.transaction(() => {
database database
.prepare( .prepare(
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc) `INSERT INTO tools (id, name, start_url, description, icon_name, is_active, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run(id, input.name, input.startUrl, input.description, input.isActive ? 1 : 0, now, now); .run(
id,
input.name,
input.startUrl,
input.description,
input.iconName,
input.isActive ? 1 : 0,
now,
now
);
replaceFormats(id, input.formatIds); replaceFormats(id, input.formatIds);
})(); })();
} catch (cause) { } catch (cause) {
@@ -114,13 +132,14 @@ export function updateTool(id: string, rawInput: ToolInput) {
database.transaction(() => { database.transaction(() => {
const result = database const result = database
.prepare( .prepare(
`UPDATE tools SET name = ?, start_url = ?, description = ?, is_active = ?, updated_at_utc = ? `UPDATE tools SET name = ?, start_url = ?, description = ?, icon_name = ?, is_active = ?, updated_at_utc = ?
WHERE id = ?` WHERE id = ?`
) )
.run( .run(
input.name, input.name,
input.startUrl, input.startUrl,
input.description, input.description,
input.iconName,
input.isActive ? 1 : 0, input.isActive ? 1 : 0,
new Date().toISOString(), new Date().toISOString(),
id id
+4 -1
View File
@@ -3,6 +3,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import NavIcon from '#lib/components/NavIcon.svelte'; import NavIcon from '#lib/components/NavIcon.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children, data } = $props(); let { children, data } = $props();
@@ -84,7 +85,9 @@
<span>Sendewerk</span> <span>Sendewerk</span>
</a> </a>
<form method="POST" action="/logout"> <form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button> <button class="mobile-logout" type="submit" aria-label="Abmelden"
><AppIcon name="right-from-bracket" /></button
>
</form> </form>
<ThemeToggle /> <ThemeToggle />
</header> </header>
+18 -2
View File
@@ -1,11 +1,17 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived } from '#lib/server/content.js'; import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { pieces: listPieces({ statuses: ['archived'] }) }; return {
pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
deleted: true
})
};
}; };
export const actions = { export const actions = {
@@ -18,5 +24,15 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
} }
},
restoreDeleted: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
return { success: 'Gelöschtes Piece wurde wiederhergestellt.' };
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+23 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -28,8 +29,29 @@
</article>{/each} </article>{/each}
</div> </div>
{:else}<section class="quiet-panel planned-panel"> {:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div> <div class="planned-icon" aria-hidden="true"><AppIcon name="box-archive" /></div>
<h2>Keine Pieces archiviert</h2> <h2>Keine Pieces archiviert</h2>
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p> <p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
</section>{/if} </section>{/if}
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
<p class="quiet-copy">
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
</p>
{#if data.deletedPieces.length}<div class="archive-list">
{#each data.deletedPieces as piece (piece.id)}<article>
<div>
<h2>{piece.title}</h2>
{#if piece.summary}<p>{piece.summary}</p>{/if}
</div>
<form method="POST" action="?/restoreDeleted">
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/><button class="secondary-button" type="submit">Wiederherstellen</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Keine gelöschten Pieces.</p>{/if}
</section>
</div> </div>
+11 -1
View File
@@ -1,7 +1,7 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; 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 }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
@@ -26,5 +26,15 @@ export const actions = {
: 'Ein Titel ist erforderlich.' : '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; } satisfies Actions;
+26 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -45,18 +46,37 @@
{#if data.pieces.length} {#if data.pieces.length}
<section class="inbox-list"> <section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2> <h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
{#each data.pieces as piece (piece.id)}<a {#each data.pieces as piece (piece.id)}<article class="inbox-item">
class="inbox-item" <a href={resolve('/fundus/[id]', { id: piece.id })}
href={resolve('/fundus/[id]', { id: piece.id })}
><span ><span
><strong>{piece.title}</strong><small ><strong>{piece.title}</strong><small
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small >{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
></span ></span
><span>Ausarbeiten →</span></a ><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
>{/each} >
<details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
>
<div class="piece-actions-popover">
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div>
</details>
</article>{/each}
</section> </section>
{:else}<section class="quiet-panel planned-panel"> {:else}<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div> <div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
<h2>Eingang ist leer</h2> <h2>Eingang ist leer</h2>
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p> <p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if} </section>{/if}
+14
View File
@@ -9,6 +9,7 @@ import {
listPieces, listPieces,
listPieceTemplates, listPieceTemplates,
setPieceArchived, setPieceArchived,
setPieceDeleted,
setPieceFavorite setPieceFavorite
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getPieceUsageContexts } from '#lib/server/shows.js'; import { getPieceUsageContexts } from '#lib/server/shows.js';
@@ -104,5 +105,18 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(404, { intent: 'favorite', error: actionError(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; } satisfies Actions;
+35 -19
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import { formatClock } from '#lib/format.js'; import { formatClock } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -65,14 +66,16 @@
<strong>{visiblePieces.length}</strong><span>Inhalte</span> <strong>{visiblePieces.length}</strong><span>Inhalte</span>
</div> </div>
<details class="template-picker"> <details class="template-picker">
<summary><span aria-hidden="true">+</span> Neues Piece</summary> <summary><AppIcon name="plus" /> Neues Piece</summary>
<div class="template-popover"> <div class="template-popover">
<div class="template-grid"> <div class="template-grid">
{#each data.templates as template (template.id)} {#each data.templates as template (template.id)}
<form method="POST" action="?/create"> <form method="POST" action="?/create">
<input type="hidden" name="template" value={template.id} /> <input type="hidden" name="template" value={template.id} />
<button type="submit" <button type="submit"
><strong>{template.name}</strong><span>{template.description}</span></button ><AppIcon name={template.iconName} size="1.35rem" /><strong
>{template.name}</strong
><span>{template.description}</span></button
> >
</form> </form>
{/each} {/each}
@@ -110,7 +113,7 @@
> >
<div class="fundus-search-row"> <div class="fundus-search-row">
<label class="search-field" <label class="search-field"
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input ><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
name="q" name="q"
type="search" type="search"
value={data.filters.query} value={data.filters.query}
@@ -119,7 +122,7 @@
> >
<button class="secondary-button" type="submit">Suchen</button> <button class="secondary-button" type="submit">Suchen</button>
<details class="filter-picker"> <details class="filter-picker">
<summary><span aria-hidden="true">≡</span> Weitere Filter</summary> <summary><AppIcon name="list-check" /> Weitere Filter</summary>
<div class="filter-panel"> <div class="filter-panel">
<label <label
><span>Status</span><select name="status" ><span>Status</span><select name="status"
@@ -204,7 +207,7 @@
aria-label={piece.isFavorite aria-label={piece.isFavorite
? `${piece.title} aus Favoriten entfernen` ? `${piece.title} aus Favoriten entfernen`
: `${piece.title} als Favorit markieren`} : `${piece.title} als Favorit markieren`}
aria-pressed={piece.isFavorite}>★</button aria-pressed={piece.isFavorite}><AppIcon name="star" /></button
> >
</form> </form>
{#if piece.mediaId}<img {#if piece.mediaId}<img
@@ -212,14 +215,11 @@
src={`/media/${piece.mediaId}`} src={`/media/${piece.mediaId}`}
alt="" alt=""
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true"> />{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none"> <AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
<path d="M4.5 6.5h15v11h-15zM7.5 14l3-3 2.2 2.2 1.8-1.7 2.5 2.5" />
<circle cx="15.8" cy="9.2" r="1.2" />
</svg>
</span>{/if} </span>{/if}
<div class="piece-primary"> <div class="piece-primary">
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2> <h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p> {#if piece.summary}<p>{piece.summary}</p>{/if}
<PieceUsageHistory history={piece.sentHistory} /> <PieceUsageHistory history={piece.sentHistory} />
</div> </div>
<div class="piece-classification"> <div class="piece-classification">
@@ -227,23 +227,24 @@
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span ><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
> >
<div class="piece-tags"> <div class="piece-tags">
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span {#if piece.formatId}<span>{piece.format}</span
>{topic.name}</span >{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
>{/each}
</div> </div>
</div> </div>
<div class="piece-state"> <div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label" <span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span >{statusLabel[piece.status]}</span
><span class="piece-facts" >{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span >{#if piece.durationSeconds}<span
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
>{/if}{#if piece.sourceCount}<span
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span >{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
></span >{/if}</span
>{/if}
> >
</div> </div>
<details class="piece-actions"> <details class="piece-actions">
<summary aria-label={`Aktionen für ${piece.title}`} <summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
><span aria-hidden="true">•••</span></summary
> >
<div class="piece-actions-popover"> <div class="piece-actions-popover">
<form method="POST" action="?/duplicate"> <form method="POST" action="?/duplicate">
@@ -259,6 +260,21 @@
value={piece.revision} value={piece.revision}
/><button class="text-button danger-text" type="submit">Archivieren</button> /><button class="text-button danger-text" type="submit">Archivieren</button>
</form> </form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm(
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
) && event.preventDefault()}
>
<input type="hidden" name="pieceId" value={piece.id} /><input
type="hidden"
name="revision"
value={piece.revision}
/>
<button class="text-button danger-text" type="submit">Piece löschen</button>
</form>
</div> </div>
</details> </details>
</article> </article>
@@ -266,7 +282,7 @@
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Kein passendes Piece</h2> <h2>Kein passendes Piece</h2>
<p>Andere Filter wählen oder ein neues Piece anlegen.</p> <p>Andere Filter wählen oder ein neues Piece anlegen.</p>
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a> <a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
+19 -1
View File
@@ -11,6 +11,7 @@ import {
savePiece, savePiece,
savePieceAsTemplate, savePieceAsTemplate,
setPieceArchived, setPieceArchived,
setPieceDeleted,
setPieceFavorite, setPieceFavorite,
uploadPieceImage, uploadPieceImage,
type EditablePieceStatus, type EditablePieceStatus,
@@ -88,6 +89,7 @@ function pieceInput(data: FormData): PieceInput {
presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1', presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1',
presentationImageManual: text(data, 'presentationImageManual') === '1', presentationImageManual: text(data, 'presentationImageManual') === '1',
presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null, presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null,
presentationMediaId: text(data, 'presentationMediaId') || null,
topicIds: data.getAll('topicIds').map(String), topicIds: data.getAll('topicIds').map(String),
categoryIds: data.getAll('categoryIds').map(String), categoryIds: data.getAll('categoryIds').map(String),
sources, sources,
@@ -206,7 +208,13 @@ export const actions = {
if (!(image instanceof File)) if (!(image instanceof File))
return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' }); return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' });
try { 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) { } catch (cause) {
const code = cause instanceof Error ? cause.message : ''; const code = cause instanceof Error ? cause.message : '';
if (code === 'CONFLICT') if (code === 'CONFLICT')
@@ -264,5 +272,15 @@ export const actions = {
} catch (cause) { } catch (cause) {
return fail(404, { intent: 'favorite', error: saveError(cause).error }); 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; } satisfies Actions;
+112 -56
View File
@@ -4,6 +4,9 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import MarkdownEditorField from '#lib/components/MarkdownEditorField.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 PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte'; import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
import type { PresentationContent } from '#lib/server/presentation.js'; import type { PresentationContent } from '#lib/server/presentation.js';
@@ -15,7 +18,6 @@
let revision = $state(untrack(() => data.piece.revision)); let revision = $state(untrack(() => data.piece.revision));
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool })))); let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
let toolToAdd = $state('');
let rendered = $state(untrack(() => ({ ...data.rendered }))); let rendered = $state(untrack(() => ({ ...data.rendered })));
let activeSection = $state<'script' | 'research' | 'presentation'>('script'); let activeSection = $state<'script' | 'research' | 'presentation'>('script');
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle'); let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
@@ -44,7 +46,12 @@
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id }) ? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
: '' : ''
); );
let previewContent = $derived<PresentationContent>({ let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
let previewContent = $derived<
PresentationContent & { images: { id: string; altText: string }[] }
>({
mode: draft.presentationMode, mode: draft.presentationMode,
topics: data.taxonomy.topics topics: data.taxonomy.topics
.filter((topic) => draft.topicIds.includes(topic.id)) .filter((topic) => draft.topicIds.includes(topic.id))
@@ -69,8 +76,16 @@
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}` ? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId : draft.presentationProvider === 'tiktok' && draft.presentationProviderId
? `https://www.tiktok.com/player/v1/${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 pendingHref: string | null = null;
let allowNavigation = false; let allowNavigation = false;
@@ -152,24 +167,28 @@
scheduleSave(); scheduleSave();
} }
function addTool() { function removeTool(index: number) {
const tool = data.tools.find((item) => item.id === toolToAdd); pieceTools.splice(index, 1);
if (!tool || pieceTools.some((item) => item.toolId === tool.id)) return; scheduleSave();
}
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({ pieceTools.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
toolId: tool.id, toolId: tool.id,
name: tool.name, name: tool.name,
startUrl: tool.startUrl, startUrl: tool.startUrl,
iconName: tool.iconName,
isActive: tool.isActive, isActive: tool.isActive,
workUrl: '', workUrl: '',
note: '' note: ''
}); });
toolToAdd = '';
scheduleSave();
} }
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
function removeTool(index: number) {
pieceTools.splice(index, 1);
scheduleSave(); scheduleSave();
} }
@@ -244,22 +263,15 @@
class:active={data.piece.isFavorite} class:active={data.piece.isFavorite}
type="submit" type="submit"
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'} aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
aria-pressed={data.piece.isFavorite}>★</button aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
> >
</form> </form>
</div> </div>
<label class="piece-dialog-title"> <InlineTitleEditor
<span class="sr-only">Titel</span>
<textarea
form="piece-editor-form" form="piece-editor-form"
name="title"
required
rows="1"
maxlength="200"
aria-label="Titel"
bind:value={draft.title} bind:value={draft.title}
oninput={scheduleSave}></textarea> oncommit={scheduleSave}
</label> />
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1> <h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
{/snippet} {/snippet}
@@ -345,17 +357,16 @@
bind:value={draft.summary}></textarea></label bind:value={draft.summary}></textarea></label
> >
<fieldset> <fieldset>
<legend>Themen</legend> <legend>Themen</legend><ChoicePicker
<div class="choice-grid"> items={data.taxonomy.topics}
{#each data.taxonomy.topics as item (item.id)}<label selectedIds={draft.topicIds}
><input label="Themen"
type="checkbox" fieldName="topicIds"
name="topicIds" onselectionchange={(ids) => {
value={item.id} draft.topicIds = ids;
bind:group={draft.topicIds} scheduleSave();
/>{item.name}</label }}
>{/each} />
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Vorgeschlagene Abschnittskategorien</legend> <legend>Vorgeschlagene Abschnittskategorien</legend>
@@ -440,22 +451,20 @@
<p class="eyebrow">Arbeitsmittel</p> <p class="eyebrow">Arbeitsmittel</p>
<h3>Werkzeuge</h3> <h3>Werkzeuge</h3>
</div> </div>
<div class="tool-picker"> <ChoicePicker
<label class="sr-only" for="piece-tool-picker">Werkzeug auswählen</label> items={data.tools
<select id="piece-tool-picker" bind:value={toolToAdd}> .filter((tool) => tool.isActive)
<option value="">Werkzeug auswählen</option> .map((tool) => ({
{#each data.tools.filter((tool) => tool.isActive && !pieceTools.some((assigned) => assigned.toolId === tool.id)) as tool (tool.id)} id: tool.id,
<option value={tool.id} name: tool.name,
>{tool.name}{tool.formatIds.includes(draft.formatId ?? '') description: tool.description,
? ' · passt zum Format' iconName: tool.iconName,
: ''}</option meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
> }))}
{/each} selectedIds={pieceTools.map((tool) => tool.toolId)}
</select> label="Werkzeuge"
<button class="secondary-button" type="button" onclick={addTool} disabled={!toolToAdd} onselectionchange={selectTools}
>Hinzufügen</button />
>
</div>
</div> </div>
{#if pieceTools.length}<div class="source-list"> {#if pieceTools.length}<div class="source-list">
{#each pieceTools as tool, index (tool.id)}<div {#each pieceTools as tool, index (tool.id)}<div
@@ -463,7 +472,10 @@
> >
<input type="hidden" name="toolId" value={tool.toolId} /> <input type="hidden" name="toolId" value={tool.toolId} />
<div class="tool-assignment-name"> <div class="tool-assignment-name">
<strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small>{/if} <AppIcon name={tool.iconName || 'wrench'} /><span
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
>{/if}</span
>
</div> </div>
<label <label
>Arbeitslink<input >Arbeitslink<input
@@ -590,7 +602,7 @@
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau"> <aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
<PresentationDisplay <PresentationDisplay
content={previewContent} content={previewContent}
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage} imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
/> />
</aside> </aside>
</div> </div>
@@ -620,17 +632,44 @@
</section> </section>
</form> </form>
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}> <section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
<div class="piece-subsection-heading"> <div class="piece-subsection-heading">
<div> <div>
<p class="eyebrow">Private Dateien</p> <p class="eyebrow">Hochgeladene Bilder</p>
<h3>Material</h3> <h3>Präsentationsbild</h3>
</div> </div>
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small> <small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
</div> </div>
{#if data.piece.media.length}<div class="media-grid"> {#if data.piece.media.length}<label class="media-none"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value=""
checked={!draft.presentationMediaId}
onchange={() => {
draft.presentationMediaId = null;
scheduleSave();
}}
/> Kein hochgeladenes Bild</label
>
<div class="media-grid">
{#each data.piece.media as media (media.id)}<figure> {#each data.piece.media as media (media.id)}<figure>
<img src={`/media/${media.id}`} alt={media.altText} /> <label class="media-presentation-choice"
><input
form="piece-editor-form"
type="radio"
name="presentationMediaId"
value={media.id}
checked={draft.presentationMediaId === media.id}
onchange={() => {
draft.presentationMediaId = media.id;
scheduleSave();
}}
/><img src={`/media/${media.id}`} alt={media.altText} /><span
>Als Präsentationsbild</span
></label
>
<figcaption> <figcaption>
<span>{media.altText || media.originalName}</span> <span>{media.altText || media.originalName}</span>
<form method="POST" action="?/deleteImage"> <form method="POST" action="?/deleteImage">
@@ -662,6 +701,10 @@
><button class="secondary-button" type="submit" disabled={actionsBlocked} ><button class="secondary-button" type="submit" disabled={actionsBlocked}
>Bild hochladen</button >Bild hochladen</button
> >
<label class="upload-select"
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
verwenden</label
>
</form> </form>
</section> </section>
@@ -696,6 +739,19 @@
disabled={actionsBlocked}>Archivieren</button disabled={actionsBlocked}>Archivieren</button
> >
</form> </form>
<form
method="POST"
action="?/delete"
onsubmit={(event) =>
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
event.preventDefault()}
>
<input type="hidden" name="revision" value={revision} /><button
class="text-button danger-text"
type="submit"
disabled={actionsBlocked}>Piece löschen</button
>
</form>
</div> </div>
<div class="piece-dialog-primary-action"> <div class="piece-dialog-primary-action">
<span <span
@@ -40,8 +40,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
piece.presentationProviderId, piece.presentationProviderId,
source?.timecodeSeconds ?? null source?.timecodeSeconds ?? null
), ),
sources: piece.sources, sources: [],
images: piece.media.map((item) => ({ id: item.id, altText: item.altText })) images: piece.media
.filter((item) => item.id === piece.presentationMediaId)
.map((item) => ({ id: item.id, altText: item.altText }))
}; };
return { pieceId: piece.id, content }; return { pieceId: piece.id, content };
}; };
@@ -14,8 +14,10 @@
<div class="presentation-test-status" role="status">Isolierte Testansicht</div> <div class="presentation-test-status" role="status">Isolierte Testansicht</div>
<PresentationDisplay <PresentationDisplay
content={data.content} content={data.content}
imageSrc={data.content.mode === 'text' imageSrc={data.content.images.length
? data.content.mode === 'text'
? '/media' ? '/media'
: `/media/${data.content.images[0].id}`
: `/fundus/${data.pieceId}/praesentation/preview-image`} : `/fundus/${data.pieceId}/praesentation/preview-image`}
/> />
</main> </main>
+4 -8
View File
@@ -56,7 +56,7 @@
<meta name="robots" content="noindex, nofollow, noarchive" /> <meta name="robots" content="noindex, nofollow, noarchive" />
</svelte:head> </svelte:head>
<main class="heinzlive-output"> <main class:empty={!view.content} class="heinzlive-output">
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status"> <div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'} <span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
</div> </div>
@@ -64,16 +64,12 @@
{#key view.revision} {#key view.revision}
<PresentationDisplay <PresentationDisplay
content={view.content} content={view.content}
imageSrc={view.content.mode === 'text' imageSrc={view.content.images.length
? view.content.mode === 'text'
? `/heinzlive/${page.params.token}/media` ? `/heinzlive/${page.params.token}/media`
: `/heinzlive/${page.params.token}/media/${view.content.images[0].id}`
: `/heinzlive/${page.params.token}/preview-image`} : `/heinzlive/${page.params.token}/preview-image`}
/> />
{/key} {/key}
{:else}
<section class="output-idle">
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
<h1>Bereit für den nächsten Inhalt</h1>
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
</section>
{/if} {/if}
</main> </main>
+22 -16
View File
@@ -3,6 +3,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -183,6 +184,17 @@
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button> <button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form> </form>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
<details class="live-output-settings"> <details class="live-output-settings">
<summary>Ausgabekanal</summary> <summary>Ausgabekanal</summary>
<div> <div>
@@ -307,17 +319,6 @@
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
</form> </form>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
@@ -412,7 +413,7 @@
type="submit" type="submit"
disabled={usageIndex === 0 || disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'} section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}>↑</button aria-label={`${usage.title} nach oben`}><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form <form
@@ -429,7 +430,8 @@
type="submit" type="submit"
disabled={usageIndex === section.usages.length - 1 || disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'} section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}>↓</button aria-label={`${usage.title} nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
{/if} {/if}
@@ -438,7 +440,9 @@
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button type="submit" aria-label={`${usage.title} präsentieren`}>↗</button> <button type="submit" aria-label={`${usage.title} präsentieren`}
><AppIcon name="display" /></button
>
</form> </form>
</div> </div>
</div> </div>
@@ -465,7 +469,8 @@
<button <button
type="submit" type="submit"
disabled={reserveIndex === 0} disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}>↑</button aria-label={`${usage.title} in Reserve nach oben`}
><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}>
@@ -477,7 +482,8 @@
<button <button
type="submit" type="submit"
disabled={reserveIndex === data.live.reserve.length - 1} disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}>↓</button aria-label={`${usage.title} in Reserve nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
{#if data.live.current || data.live.sections[0]}<form {#if data.live.current || data.live.sections[0]}<form
+26 -17
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js'; import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -27,8 +28,9 @@
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<section class="show-create-panel"> <details class="show-create-panel">
<div> <summary><AppIcon name="plus" /> Neue Sendung planen</summary>
<div class="show-create-heading">
<p class="eyebrow">Neuen Stream planen</p> <p class="eyebrow">Neuen Stream planen</p>
<h2>Leer starten oder Ablauf übernehmen</h2> <h2>Leer starten oder Ablauf übernehmen</h2>
</div> </div>
@@ -45,23 +47,29 @@
<label <label
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label >Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
> >
<fieldset class="show-template-grid">
<legend>Ausgangspunkt</legend>
<label <label
>Ausgangspunkt<select name="templateId" value={form?.templateId ?? ''} ><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
><option value="">Leer starten</option name="newspaper"
>{#each data.templates as template (template.id)}<option value={template.id} /><span
>{template.name}</option ><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
>{/each}</select
></label ></label
> >
{#each data.templates as template (template.id)}<label
><input
type="radio"
name="templateId"
value={template.id}
checked={form?.templateId === template.id}
/><AppIcon name="list-check" /><span
><strong>{template.name}</strong><small>{template.description}</small></span
></label
>{/each}
</fieldset>
<button class="primary-button" type="submit">Sendung anlegen</button> <button class="primary-button" type="submit">Sendung anlegen</button>
</form> </form>
<div class="template-notes"> </details>
{#each data.templates as template (template.id)}<p>
<strong>{template.name}:</strong>
{template.description}
</p>{/each}
</div>
</section>
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <header>
@@ -86,16 +94,17 @@
<p>{berlinDate(show.startsAtUtc)} Uhr</p> <p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div> </div>
<div class="show-card-facts"> <div class="show-card-facts">
<span><strong>{show.sectionCount}</strong> Abschnitte</span><span {#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
>{/if}{#if show.totalDurationSeconds}<span
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span ><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
><b aria-hidden="true">→</b> >{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div> </div>
</a> </a>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span aria-hidden="true">+</span> <span aria-hidden="true"><AppIcon name="plus" /></span>
<h2>Noch keine Sendung</h2> <h2>Noch keine Sendung</h2>
<p>Oben kann der erste Stream geplant werden.</p> <p>Oben kann der erste Stream geplant werden.</p>
</div> </div>
+11
View File
@@ -92,6 +92,16 @@ function actionError(cause: unknown) {
if (code === 'ORIGINAL_NOT_FOUND') if (code === 'ORIGINAL_NOT_FOUND')
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' }; 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 (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') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
@@ -239,6 +249,7 @@ export const actions = {
presentationDescription: text(data, 'presentationDescription'), presentationDescription: text(data, 'presentationDescription'),
presentationImageUrl: text(data, 'presentationImageUrl'), presentationImageUrl: text(data, 'presentationImageUrl'),
presentationSiteName: text(data, 'presentationSiteName'), presentationSiteName: text(data, 'presentationSiteName'),
presentationMediaId: text(data, 'presentationMediaId') || null,
tools: toolIds.map((id, index) => ({ tools: toolIds.map((id, index) => ({
id, id,
workUrl: toolWorkUrls[index] ?? '', workUrl: toolWorkUrls[index] ?? '',
+12 -8
View File
@@ -6,6 +6,7 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte'; import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte'; import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
import { formatClock, formatDuration } from '#lib/format.js'; import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -207,7 +208,7 @@
<div class="page-wrap builder-page"> <div class="page-wrap builder-page">
<header class="page-heading show-heading"> <header class="page-heading show-heading">
<div class="show-heading-copy"> <div class="show-heading-copy">
<a class="back-link" href={resolve('/sendungen')}>← Sendungen</a> <a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a>
<h1>{data.show.title}</h1> <h1>{data.show.title}</h1>
<p class="show-date"> <p class="show-date">
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'} {data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
@@ -219,7 +220,7 @@
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span> <strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
</div> </div>
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)} <a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
>Live-Steuerung <span>↗</span></a >Live-Steuerung <AppIcon name="display" /></a
> >
</div> </div>
</header> </header>
@@ -412,7 +413,8 @@
><span>{formatClock(piece.durationSeconds)}</span> ><span>{formatClock(piece.durationSeconds)}</span>
</div> </div>
<h3> <h3>
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit">★</span {#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit"
><AppIcon name="star" /></span
>{/if}{piece.title} >{/if}{piece.title}
</h3> </h3>
<p> <p>
@@ -449,7 +451,7 @@
{/each} {/each}
</div> </div>
{#if !visiblePieces.length}<div class="empty-state"> {#if !visiblePieces.length}<div class="empty-state">
<span aria-hidden="true">⌕</span> <span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
<h2>Keine verfügbaren Pieces</h2> <h2>Keine verfügbaren Pieces</h2>
<p> <p>
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
@@ -525,7 +527,7 @@
/><input type="hidden" name="position" value={index - 1} /><button /><input type="hidden" name="position" value={index - 1} /><button
type="submit" type="submit"
disabled={index === 0} disabled={index === 0}
aria-label={`${section.name} nach oben`}>↑</button aria-label={`${section.name} nach oben`}><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection"> <form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
@@ -536,7 +538,7 @@
/><input type="hidden" name="position" value={index + 1} /><button /><input type="hidden" name="position" value={index + 1} /><button
type="submit" type="submit"
disabled={index === data.show.sections.length - 1} disabled={index === data.show.sections.length - 1}
aria-label={`${section.name} nach unten`}>↓</button aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button
> >
</form> </form>
</div> </div>
@@ -602,7 +604,8 @@
/><button /><button
type="submit" type="submit"
disabled={usageIndex === 0} disabled={usageIndex === 0}
aria-label={`${usage.title} nach oben`}>↑</button aria-label={`${usage.title} nach oben`}
><AppIcon name="arrow-up" /></button
> >
</form> </form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage"> <form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
@@ -617,7 +620,8 @@
/><button /><button
type="submit" type="submit"
disabled={usageIndex === section.usages.length - 1} disabled={usageIndex === section.usages.length - 1}
aria-label={`${usage.title} nach unten`}>↓</button aria-label={`${usage.title} nach unten`}
><AppIcon name="arrow-down" /></button
> >
</form> </form>
<UsageEditorDialog <UsageEditorDialog
+28 -1
View File
@@ -4,6 +4,7 @@ import {
createTaxonomy, createTaxonomy,
deleteTaxonomy, deleteTaxonomy,
getTaxonomy, getTaxonomy,
updateTaxonomy,
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
@@ -42,6 +43,7 @@ function actionError(cause: unknown): string {
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.'; 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_COLOR') return 'Bitte eine gültige Farbe wählen.';
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.'; if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
if (code === 'INVALID_ICON') return 'Bitte ein Icon aus der angebotenen Auswahl verwenden.';
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.'; if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
if (code === 'INVALID_TOOL_URL') if (code === 'INVALID_TOOL_URL')
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.'; return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
@@ -56,6 +58,7 @@ function toolInput(data: FormData): ToolInput {
name: String(data.get('name') || ''), name: String(data.get('name') || ''),
startUrl: String(data.get('startUrl') || ''), startUrl: String(data.get('startUrl') || ''),
description: String(data.get('description') || ''), description: String(data.get('description') || ''),
iconName: String(data.get('iconName') || ''),
isActive: data.get('isActive') === '1', isActive: data.get('isActive') === '1',
formatIds: data.getAll('formatIds').map(String) formatIds: data.getAll('formatIds').map(String)
}; };
@@ -130,12 +133,36 @@ export const actions = {
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' }); return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
} }
try { try {
createTaxonomy(kind, String(data.get('name') || ''), String(data.get('color') || '')); createTaxonomy(
kind,
String(data.get('name') || ''),
String(data.get('color') || ''),
String(data.get('iconName') || '')
);
return { intent: 'taxonomy', success: 'Eintrag angelegt.' }; return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
} catch (cause) { } catch (cause) {
return fail(400, { intent: 'taxonomy', error: actionError(cause) }); return fail(400, { intent: 'taxonomy', error: actionError(cause) });
} }
}, },
updateTaxonomy: 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 {
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 }) => { deleteTaxonomy: async ({ locals, request }) => {
requireBarkeeper(locals.user); requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+58 -82
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
function confirmTaxonomyRemoval(event: SubmitEvent) { function confirmTaxonomyRemoval(event: SubmitEvent) {
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault(); if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
@@ -120,81 +124,38 @@
<p class="eyebrow">Redaktionelle Hilfsmittel</p> <p class="eyebrow">Redaktionelle Hilfsmittel</p>
<h2>Werkzeugkatalog</h2> <h2>Werkzeugkatalog</h2>
</div> </div>
<small>Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.</small> <button class="compact-add-button" type="button" onclick={() => toolDialog.showModal()}>
<AppIcon name="plus" /> Werkzeug hinzufügen
</button>
</div> </div>
<div class="tool-settings-list"> <p class="catalog-note">
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
</p>
<div class="catalog-settings-list tool-settings-list">
{#each data.tools as tool (tool.id)} {#each data.tools as tool (tool.id)}
<details class:inactive={!tool.isActive}> <button
<summary type="button"
><strong>{tool.name}</strong><span>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}</span class:inactive={!tool.isActive}
></summary aria-label={`${tool.name} bearbeiten`}
onclick={() => toolDialog.showModal(tool)}
> >
<form class="compact-form tool-form" method="POST" action="?/updateTool"> <span class="catalog-entry-icon"><AppIcon name={tool.iconName || 'wrench'} /></span>
<input type="hidden" name="toolId" value={tool.id} /> <span class="catalog-entry-copy">
<label>Name<input name="name" required maxlength="120" value={tool.name} /></label> <strong>{tool.name}</strong>
<label <small
>Start-URL<input >{tool.isActive ? 'Aktiv' : 'Deaktiviert'}{#if tool.formatIds.length}
name="startUrl" · {data.taxonomy.formats
type="url" .filter((format) => tool.formatIds.includes(format.id))
maxlength="2000" .map((format) => format.name)
value={tool.startUrl} .join(', ')}{/if}</small
placeholder="https://…"
/></label
> >
<label class="wide-field" </span>
>Beschreibung<textarea name="description" rows="2" maxlength="2000" <AppIcon name="pen" />
>{tool.description}</textarea </button>
></label
>
<fieldset class="wide-field">
<legend>Passende Formate</legend>
<div class="choice-grid">
{#each data.taxonomy.formats as format (format.id)}<label
><input
type="checkbox"
name="formatIds"
value={format.id}
checked={tool.formatIds.includes(format.id)}
/>{format.name}</label
>{/each}
</div>
</fieldset>
<label class="tool-active"
><input type="checkbox" name="isActive" value="1" checked={tool.isActive} /> Aktiv</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
</details>
{/each} {/each}
</div> </div>
<details class="new-tool">
<summary>Werkzeug hinzufügen</summary>
<form class="compact-form tool-form" method="POST" action="?/createTool">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Start-URL<input
name="startUrl"
type="url"
maxlength="2000"
placeholder="https://…"
/></label
>
<label class="wide-field"
>Beschreibung<textarea name="description" rows="2" maxlength="2000"></textarea></label
>
<fieldset class="wide-field">
<legend>Passende Formate</legend>
<div class="choice-grid">
{#each data.taxonomy.formats as format (format.id)}<label
><input type="checkbox" name="formatIds" value={format.id} />{format.name}</label
>{/each}
</div>
</fieldset>
<input type="hidden" name="isActive" value="1" />
<button class="primary-button" type="submit">Werkzeug anlegen</button>
</form>
</details>
</section> </section>
<ToolEditorDialog bind:this={toolDialog} formats={data.taxonomy.formats} />
<h2 class="section-heading">Taxonomie</h2> <h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid"> <div class="settings-grid">
@@ -209,7 +170,9 @@
type="hidden" type="hidden"
name="taxonomyId" name="taxonomyId"
value={category.id} value={category.id}
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button> /><button type="submit" aria-label={`${category.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span </form></span
>{/each} >{/each}
</div> </div>
@@ -241,7 +204,9 @@
type="hidden" type="hidden"
name="taxonomyId" name="taxonomyId"
value={format.id} value={format.id}
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button> /><button type="submit" aria-label={`${format.name} entfernen`}
><AppIcon name="trash" /></button
>
</form></span </form></span
>{/each} >{/each}
</div> </div>
@@ -256,20 +221,31 @@
><button class="text-button" type="submit">Hinzufügen</button> ><button class="text-button" type="submit">Hinzufügen</button>
</form> </form>
</section> </section>
<section class="quiet-panel taxonomy-panel"> <section class="quiet-panel taxonomy-panel topic-management-panel">
<p class="eyebrow">Themen</p> <p class="eyebrow">Themen</p>
<h2>Inhaltliche Zuordnung</h2> <h2>Inhaltliche Zuordnung</h2>
<div class="taxonomy-list editable-taxonomy"> <div class="topic-settings-list">
{#each data.taxonomy.topics as topic (topic.id)}<span {#each data.taxonomy.topics as topic (topic.id)}
>{topic.name} <details>
<summary
><AppIcon name={topic.iconName || 'circle'} /><strong>{topic.name}</strong><AppIcon
name="pen"
/></summary
>
<form class="topic-edit-form" method="POST" action="?/updateTaxonomy">
<input type="hidden" name="kind" value="topic" />
<input type="hidden" name="taxonomyId" value={topic.id} />
<label>Name<input name="name" required maxlength="80" value={topic.name} /></label>
<IconPicker name={topic.iconName} />
<button class="secondary-button" type="submit">Änderungen speichern</button>
</form>
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}> <form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
<input type="hidden" name="kind" value="topic" /><input <input type="hidden" name="kind" value="topic" />
type="hidden" <input type="hidden" name="taxonomyId" value={topic.id} />
name="taxonomyId" <button class="text-button danger-text" type="submit">Thema löschen</button>
value={topic.id} </form>
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button> </details>
</form></span {/each}
>{/each}
</div> </div>
<form class="taxonomy-form" method="POST" action="?/createTaxonomy"> <form class="taxonomy-form" method="POST" action="?/createTaxonomy">
<input type="hidden" name="kind" value="topic" /><label <input type="hidden" name="kind" value="topic" /><label
@@ -279,7 +255,7 @@
maxlength="80" maxlength="80"
placeholder="Neues Thema" placeholder="Neues Thema"
/></label /></label
><button class="text-button" type="submit">Hinzufügen</button> ><IconPicker name="" /><button class="text-button" type="submit">Hinzufügen</button>
</form> </form>
</section> </section>
</div> </div>
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 3.7 MiB

+104 -40
View File
@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -40,6 +40,12 @@ async function openEditorialField(page: Page, heading: string) {
} }
} }
async function editInlineTitle(scope: Page | Locator, title: string, value: string) {
await scope.getByRole('button', { name: `${title} Titel bearbeiten` }).click();
await scope.getByLabel('Titel', { exact: true }).fill(value);
await scope.getByLabel('Titel', { exact: true }).press('Enter');
}
async function login(page: Page, email = adminEmail, password = adminPassword) { async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('E-Mail').fill(email); await page.getByLabel('E-Mail').fill(email);
@@ -413,8 +419,9 @@ test('sent and planned source filters stay independent and removing the last usa
}); });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByRole('button', { name: 'Filtern' }).click();
@@ -564,8 +571,9 @@ test('a show can be planned from a template with independent usages and reserve'
test.slow(); test.slow();
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen'); await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC'); await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url(); const showUrl = page.url();
@@ -647,7 +655,11 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await usages.nth(0).getByLabel('Titel', { exact: true }).fill('Konsole als Auftakt'); await editInlineTitle(
usages.nth(0),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Auftakt'
);
await usages.nth(0).locator('.piece-meta-details > summary').click(); await usages.nth(0).locator('.piece-meta-details > summary').click();
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5'); await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click();
@@ -668,7 +680,11 @@ test('a show can be planned from a template with independent usages and reserve'
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
}) })
.click(); .click();
await usages.nth(1).getByLabel('Titel', { exact: true }).fill('Konsole als Rückblick'); await editInlineTitle(
usages.nth(1),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Rückblick'
);
await usages.nth(1).locator('.piece-meta-details > summary').click(); await usages.nth(1).locator('.piece-meta-details > summary').click();
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15'); await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click();
@@ -879,34 +895,56 @@ test('inbox, archive and administration stay concise', async ({ page }) => {
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => { test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung'); await page.goto('/verwaltung');
const create = page.locator('.new-tool'); await page.getByRole('button', { name: 'Werkzeug hinzufügen' }).click();
await create.getByText('Werkzeug hinzufügen').click(); const create = page.getByRole('dialog', { name: 'Neues Werkzeug' });
await create.getByLabel('Name').fill('E2E Tierlist'); await create.getByLabel('Name').fill('E2E Tierlist');
await create.getByLabel('Start-URL').fill('https://example.com/tierlist'); await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung'); await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
await create.getByLabel('Quiz').check(); await create.getByText('Icon auswählen', { exact: true }).click();
await create.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-desktop.png') });
await create.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await create.getByText('Formate auswählen', { exact: true }).click();
await create.getByRole('checkbox', { name: 'Quiz' }).check();
await create.getByRole('button', { name: 'Auswahl schließen' }).click();
await page.screenshot({ path: testInfo.outputPath('tool-create-desktop.png'), fullPage: true });
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click(); await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
const tool = page.locator('.tool-settings-list details').filter({ hasText: 'E2E Tierlist' }); const tool = page.locator('.tool-settings-list > button').filter({ hasText: 'E2E Tierlist' });
await expect(tool).toContainText('Aktiv'); await expect(tool).toContainText('Aktiv');
await expect(tool.locator('.catalog-entry-icon svg')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true });
await page.goto('/fundus/piece-quiz'); await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page await page.getByText('Werkzeuge auswählen', { exact: true }).click();
.locator('#piece-tool-picker') const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
.selectOption({ label: 'E2E Tierlist · passt zum Format' }); await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
await page.getByRole('button', { name: 'Hinzufügen', exact: true }).click(); await toolChoice.check();
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board'); await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren'); await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' });
await toolsSection.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') });
await page.goto('/verwaltung'); await page.goto('/verwaltung');
const storedTool = page const storedTool = page
.locator('.tool-settings-list details') .locator('.tool-settings-list > button')
.filter({ hasText: 'E2E Tierlist' }); .filter({ hasText: 'E2E Tierlist' });
await storedTool.getByText('E2E Tierlist', { exact: true }).click(); await storedTool.click();
await storedTool.getByLabel('Aktiv', { exact: true }).uncheck(); const edit = page.getByRole('dialog', { name: 'E2E Tierlist' });
await storedTool.getByRole('button', { name: 'Speichern' }).click(); await edit.getByRole('checkbox', { name: /^Aktiv/ }).uncheck();
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(edit.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await edit.getByText('Icon auswählen', { exact: true }).click();
await edit.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-mobile.png') });
await edit.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('tool-edit-mobile.png') });
await edit.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(storedTool).toContainText('Deaktiviert'); await expect(storedTool).toContainText('Deaktiviert');
await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true });
await page.goto('/fundus/piece-quiz'); await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
@@ -916,8 +954,12 @@ test('tools stay editable in pieces after catalog deactivation', async ({ page }
'https://example.com/tierlist/e2e-board' 'https://example.com/tierlist/e2e-board'
); );
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page
.locator('.piece-source-editor')
.filter({ hasText: 'Werkzeuge' })
.scrollIntoViewIfNeeded();
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') });
}); });
test('validated YouTube and TikTok players switch without exposing private fields', async ({ test('validated YouTube and TikTok players switch without exposing private fields', async ({
@@ -941,7 +983,8 @@ test('validated YouTube and TikTok players switch without exposing private field
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
const output = await popupPromise; const output = await popupPromise;
await expect(output.getByText('Bereit für den nächsten Inhalt')).toBeVisible(); await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click(); await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
const youtube = output.locator('iframe[title="YouTube Vorschau"]'); const youtube = output.locator('iframe[title="YouTube Vorschau"]');
@@ -949,6 +992,9 @@ test('validated YouTube and TikTok players switch without exposing private field
'src', 'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75' 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
); );
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(youtube).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await expect(output.locator('.output-connection')).toHaveClass(/sr-only/); await expect(output.locator('.output-connection')).toHaveClass(/sr-only/);
const publicState = await output.evaluate(async () => const publicState = await output.evaluate(async () =>
JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json())) JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json()))
@@ -986,7 +1032,7 @@ test('live control persists state and exposes only explicit presentation data',
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click(); await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
const png = Buffer.from( const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
@@ -998,6 +1044,13 @@ test('live control persists state and exposes only explicit presentation data',
}); });
await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild');
await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.getByRole('button', { name: 'Bild hochladen' }).click();
await page.goto('/sendungen/show-october');
const plannedConsole = page
.locator('.editable-usage')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.first();
await plannedConsole.getByRole('button', { name: /bearbeiten und verschieben/ }).click();
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click();
await page.goto('/live?show=show-october'); await page.goto('/live?show=show-october');
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
@@ -1030,9 +1083,8 @@ test('live control persists state and exposes only explicit presentation data',
const anonymous = await browser.newContext(); const anonymous = await browser.newContext();
const output = await anonymous.newPage(); const output = await anonymous.newPage();
await output.goto(outputUrl); await output.goto(outputUrl);
await expect( await expect(output.locator('.output-content')).toHaveCount(0);
output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' }) await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
).toBeVisible();
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await expect( await expect(
@@ -1040,9 +1092,9 @@ test('live control persists state and exposes only explicit presentation data',
).toBeVisible({ ).toBeVisible({
timeout: 10_000 timeout: 10_000
}); });
await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); await expect(output.getByRole('heading', { name: 'Weniger Leistung, mehr Idee' })).toBeVisible();
await expect(output.locator('.output-topics')).toHaveText('Gaming'); await expect(output.locator('.output-topics')).toHaveText('Gaming');
await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toHaveCount(0); await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible();
await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true });
const outputHtml = await output.content(); const outputHtml = await output.content();
expect(outputHtml).not.toContain('Herstellerangaben'); expect(outputHtml).not.toContain('Herstellerangaben');
@@ -1060,6 +1112,10 @@ test('live control persists state and exposes only explicit presentation data',
expect(outputJson).not.toContain('Herstellerangaben'); expect(outputJson).not.toContain('Herstellerangaben');
expect(outputJson).not.toContain('Moderationsstichpunkte'); expect(outputJson).not.toContain('Moderationsstichpunkte');
expect(outputJson).not.toContain('live-konsole.png'); expect(outputJson).not.toContain('live-konsole.png');
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await page.getByRole('button', { name: 'Als Nächstes' }).click(); await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.next-card')).toContainText('Pixelart'); await expect(page.locator('.next-card')).toContainText('Pixelart');
@@ -1105,7 +1161,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url(); const pieceUrl = page.url();
await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück mit Markdown'); await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click(); await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/); await expect(page).toHaveURL(/\/fundus$/);
@@ -1117,6 +1173,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
.fill('Persistiert nach Neuladen und ist gezielt suchbar.'); .fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByText('Themen auswählen', { exact: true }).click();
await page.getByLabel('Gaming').check(); await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
await page await page
@@ -1147,7 +1204,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await page.reload(); await page.reload();
await expect(page.getByLabel('Titel', { exact: true })).toHaveValue('E2E Fundstück mit Markdown'); await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible();
await page.getByText('Status, Dauer und Einordnung').click(); await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Private Recherche'); await openEditorialField(page, 'Private Recherche');
@@ -1159,6 +1218,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64' 'base64'
); );
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByLabel('Bild', { exact: true }).setInputFiles({ await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png', name: 'redaktionsbild.png',
mimeType: 'image/png', mimeType: 'image/png',
@@ -1179,7 +1239,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
const concurrent = await page.context().newPage(); const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl); await concurrent.goto(pieceUrl);
await page.getByLabel('Titel', { exact: true }).fill('E2E Fundstück, extern aktualisiert'); await editInlineTitle(page, 'E2E Fundstück mit Markdown', 'E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await concurrent.getByText('Status, Dauer und Einordnung').click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await concurrent await concurrent
@@ -1189,9 +1249,9 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
timeout: 10_000 timeout: 10_000
}); });
await concurrent.reload(); await concurrent.reload();
await expect(concurrent.getByLabel('Titel', { exact: true })).toHaveValue( await expect(
'E2E Fundstück, extern aktualisiert' concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' })
); ).toBeVisible();
await concurrent.getByText('Status, Dauer und Einordnung').click(); await concurrent.getByText('Status, Dauer und Einordnung').click();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.' 'Diese veraltete Änderung darf nicht gewinnen.'
@@ -1214,12 +1274,18 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
await expect(page.locator('.piece-card')).toHaveCount(1); await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card .piece-actions > summary').click(); await page.locator('.piece-card .piece-actions > summary').click();
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click(); await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(page.getByLabel('Titel', { exact: true })).toHaveValue( await expect(
'Kopie von E2E Fundstück, extern aktualisiert' page.getByRole('button', {
); name: 'Kopie von E2E Fundstück, extern aktualisiert Titel bearbeiten'
})
).toBeVisible();
await page.context().setOffline(true); await page.context().setOffline(true);
await page.getByLabel('Titel', { exact: true }).fill('Kopie mit unterbrochener Verbindung'); await editInlineTitle(
page,
'Kopie von E2E Fundstück, extern aktualisiert',
'Kopie mit unterbrochener Verbindung'
);
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', { await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000 timeout: 10_000
}); });
@@ -1298,15 +1364,13 @@ test('link preview uses one existing source, persists manual fields and isolates
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0'); await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
await page.getByLabel('Titel', { exact: true }).last().fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload(); await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue( await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau');
'Manuell korrigierte Linkvorschau'
);
const previewPromise = page.waitForEvent('popup'); const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click(); await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
@@ -1317,7 +1381,7 @@ test('link preview uses one existing source, persists manual fields and isolates
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' }) preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible(); ).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible(); await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(0); await expect(preview.locator('.output-link-image')).toHaveCount(1);
await preview.screenshot({ await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'), path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true fullPage: true