Release 0.5.2
This commit is contained in:
1 parent
fb24d171ae
commit
25da870601
10 files changed
+1308
-298
No files matched your search
@@ -189,3 +189,29 @@
|
|||||||
Pixeln visuell kontrolliert
|
Pixeln visuell kontrolliert
|
||||||
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
|
|
||||||
|
## Redaktionelles Piece-Arbeitsblatt
|
||||||
|
|
||||||
|
| Kriterium | Nachweis | Stand |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| Schreiben steht im Mittelpunkt | Desktop- und Mobilprüfung zeigt Fundstück, Recherche und drei Sendungstexte im primären Lesefluss | erfüllt am 22.10.2026 |
|
||||||
|
| Kontext bleibt vollständig erreichbar | Quellen, Einordnung, Präsentation und Bilder auf Desktop sowie als vier mobile Werkzeuge geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Textfelder unabhängig bearbeitbar | Recherche und Moderationsstichpunkte im Browser gleichzeitig geöffnet und gespeichert | erfüllt am 22.10.2026 |
|
||||||
|
| Bestehender Formvertrag bleibt erhalten | Autosave, Reload, Taxonomie, Quelle, Markdown, Upload, Archiv und Duplikat im Browser durchlaufen | erfüllt am 22.10.2026 |
|
||||||
|
| Konflikte und Speicherfehler sichtbar | parallele Revision sowie Offline-Autosave mit erfolgreicher manueller Wiederholung geprüft | erfüllt am 22.10.2026 |
|
||||||
|
| Schneller Video-Einstieg ohne Einordnung | Link im Eingang aufgenommen, direkt geöffnet, beschrieben und isoliert als Video getestet | erfüllt am 22.10.2026 |
|
||||||
|
| Responsive Darstellung und beide Themes | 1440 × 1000 und 390 × 844 in Light/Dark ohne horizontalen Überlauf visuell kontrolliert | erfüllt am 22.10.2026 |
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen Piece-Arbeitsblatt
|
||||||
|
|
||||||
|
- `npm run lint`: erfolgreich
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen
|
||||||
|
- `npm test`: 29 Tests in 6 Dateien erfolgreich
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
|
||||||
|
- gezielter Playwright-Lauf für Autosave, Offline-Fehler, Konflikt und Upload:
|
||||||
|
1 von 1 Browsertest erfolgreich
|
||||||
|
- `npm run test:e2e`: 15 von 15 Browsertests erfolgreich
|
||||||
|
- Desktop Light/Dark sowie Smartphone Light/Dark und mobiles
|
||||||
|
Präsentationswerkzeug visuell kontrolliert; langer Titel bricht vollständig um,
|
||||||
|
Werkzeugfläche ist opak und die Speicherleiste überdeckt mobil keine Eingaben
|
||||||
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
@@ -239,6 +239,18 @@ 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.
|
||||||
|
|
||||||
|
Der eigenständige Piece-Editor ordnet denselben Formvertrag als redaktionelles
|
||||||
|
Arbeitsblatt an. Titel, Fundstück, private Recherche sowie Einleitung,
|
||||||
|
Moderationsstichpunkte und Überleitung bilden die primäre Schreibfläche.
|
||||||
|
Quellen, Einordnung, Präsentation und eine Bildzusammenfassung liegen auf breiten
|
||||||
|
Ansichten in einer kontextuellen Seitenleiste; auf kleinen Ansichten öffnen
|
||||||
|
dieselben DOM-Formfelder als fokussierbare Werkzeugflächen und werden dabei nicht
|
||||||
|
neu erzeugt oder ausgehängt. `EditorialMarkdownField` hält Lese- und
|
||||||
|
Bearbeitungszustand je Textfeld lokal, während alle Werte weiterhin gemeinsam
|
||||||
|
über die bestehende `save`-Action und Piece-Revision gespeichert werden. Die
|
||||||
|
separate Bild-Action und alle Präsentationsactions bleiben eigene serverseitige
|
||||||
|
Mutationen.
|
||||||
|
|
||||||
## Sicherheitsgrenzen
|
## Sicherheitsgrenzen
|
||||||
|
|
||||||
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
|
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ als bereits implementiert verstanden werden.
|
|||||||
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
|
Verwendungshistorie; gesendet bedeutet erledigt in abgeschlossener Sendung
|
||||||
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
|
- standardmäßig ausgeblendete Pieces, die in mindestens einer noch nicht
|
||||||
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
|
abgeschlossenen Sendung eingeplant sind, mit unabhängigem Sofortfilter
|
||||||
|
- der eigenständige Piece-Editor ist als redaktionelles Arbeitsblatt aufgebaut:
|
||||||
|
Titel, Fundstück, private Recherche und die getrennt gespeicherten
|
||||||
|
Sendungstexte stehen im Schreibfluss; Quellen, Einordnung, Präsentation und
|
||||||
|
Bilder begleiten ihn als Desktop-Kontextleiste oder mobile Werkzeuge
|
||||||
|
- Markdown-Felder wechseln einzeln zwischen Lesen und Bearbeiten, sodass mehrere
|
||||||
|
Textbereiche gleichzeitig offen bleiben; Autosave, Konfliktschutz und
|
||||||
|
sichtbare Speicherfehler verwenden unverändert die Piece-Revision
|
||||||
|
- die Schnellaufnahme „Aufnehmen und öffnen“ leitet nach erfolgreicher Anlage
|
||||||
|
direkt zum neuen Piece weiter, statt den SvelteKit-Redirect als
|
||||||
|
Validierungsfehler abzufangen
|
||||||
|
|
||||||
## Umgesetzter Stand Etappe 4
|
## Umgesetzter Stand Etappe 4
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
|
|||||||
+585
-1
@@ -2544,6 +2544,467 @@ a {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worksheet-page {
|
||||||
|
max-width: 94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worksheet-editor {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worksheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worksheet-heading {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worksheet-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worksheet-title textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.2em;
|
||||||
|
padding: 0;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
outline-offset: 0.3rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: clamp(1.65rem, 3.3vw, 2.8rem);
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(19rem, 23rem);
|
||||||
|
align-items: start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-worksheet {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature.has-image {
|
||||||
|
grid-template-columns: minmax(9rem, 0.32fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 8.5rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature h2,
|
||||||
|
.writing-sheet h2,
|
||||||
|
.context-card h2 {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature p:not(.eyebrow) {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature small {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-feature a,
|
||||||
|
.source-feature .text-button {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.writing-sheet {
|
||||||
|
padding: clamp(1rem, 2vw, 1.6rem);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.writing-sheet-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.writing-sheet-heading small,
|
||||||
|
.script-heading > p:last-child {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field {
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field.private-field {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gold) 38%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--gold) 5%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field header p {
|
||||||
|
margin: 0.18rem 0 0;
|
||||||
|
color: var(--gold-strong);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-edit-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-input {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-input textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 9rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-field > .markdown-preview {
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-empty {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-empty:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-empty small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-heading {
|
||||||
|
padding: 1.4rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-heading > p:last-child {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-sections .editorial-field:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-context {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
gap: 0.7rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
background: color-mix(in srgb, var(--panel) 96%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-close,
|
||||||
|
.mobile-editor-tools {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-empty,
|
||||||
|
.context-summary-copy,
|
||||||
|
.asset-summary p,
|
||||||
|
.audience-preview p {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-wide-action {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-list {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.65fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-row label:nth-child(2) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-row label:nth-child(3) {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-row > button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card .source-row label,
|
||||||
|
.context-fields > label,
|
||||||
|
.presentation-settings > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-card input:not([type='checkbox'], [type='radio']),
|
||||||
|
.context-card select,
|
||||||
|
.context-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.58rem;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-summary,
|
||||||
|
.context-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-summary > *,
|
||||||
|
.context-tags span {
|
||||||
|
padding: 0.3rem 0.48rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-summary strong {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-edit-toggle {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-fields,
|
||||||
|
.presentation-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-fields[hidden],
|
||||||
|
.presentation-controls[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-fields fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-fields legend {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-fields .choice-grid label {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audience-preview {
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audience-preview > span {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audience-preview > strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audience-preview .markdown-preview {
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-card-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-card-actions .text-button {
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-card-actions .secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-controls .editorial-field {
|
||||||
|
padding: 0.2rem 0 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-controls .presentation-settings {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-controls .presentation-preview-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supporting-editor-section {
|
||||||
|
max-width: calc(100% - 24rem);
|
||||||
|
}
|
||||||
|
|
||||||
.shows-page {
|
.shows-page {
|
||||||
max-width: 78rem;
|
max-width: 78rem;
|
||||||
}
|
}
|
||||||
@@ -3272,6 +3733,12 @@ a {
|
|||||||
.source-row {
|
.source-row {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.editor-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(17rem, 20rem);
|
||||||
|
}
|
||||||
|
.supporting-editor-section {
|
||||||
|
max-width: calc(100% - 21rem);
|
||||||
|
}
|
||||||
.show-create-form {
|
.show-create-form {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -3413,12 +3880,129 @@ a {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.worksheet-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.worksheet-title textarea {
|
||||||
|
height: 2.3em;
|
||||||
|
font-size: 1.65rem;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
.mobile-editor-tools {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--mobile-header) + 0.45rem);
|
||||||
|
z-index: 12;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
.mobile-editor-tools button,
|
||||||
|
.mobile-editor-tools a {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.55rem 0.3rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mobile-editor-tools button[aria-expanded='true'] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.editor-workspace {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.source-feature.has-image {
|
||||||
|
grid-template-columns: 6.5rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.source-feature,
|
||||||
|
.source-feature img {
|
||||||
|
min-height: 7.5rem;
|
||||||
|
}
|
||||||
|
.source-feature > div {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
.writing-sheet {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
}
|
||||||
|
.writing-sheet-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.editorial-field.private-field {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.editorial-empty {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.editor-context {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.editor-context.context-open::before {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 38;
|
||||||
|
background: color-mix(in srgb, var(--text) 28%, transparent);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.editor-context .context-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.editor-context .context-card.mobile-active {
|
||||||
|
position: fixed;
|
||||||
|
inset: calc(var(--mobile-header) + 0.5rem) 0.5rem calc(var(--mobile-nav) + 0.5rem);
|
||||||
|
z-index: 40;
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.context-close {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.supporting-editor-section {
|
||||||
|
max-width: none;
|
||||||
|
scroll-margin-top: calc(var(--mobile-header) + 4.5rem);
|
||||||
|
}
|
||||||
|
.worksheet-editor:has(.editor-context.context-open) .editor-savebar {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
.editor-section {
|
.editor-section {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
border-radius: var(--radius-medium);
|
border-radius: var(--radius-medium);
|
||||||
}
|
}
|
||||||
.editor-savebar {
|
.editor-savebar {
|
||||||
bottom: calc(var(--mobile-nav) + 0.6rem);
|
position: static;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.split-section-title,
|
.split-section-title,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MarkdownPreview from './MarkdownPreview.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
inputLabel = label,
|
||||||
|
hint = '',
|
||||||
|
emptyText,
|
||||||
|
value = $bindable(),
|
||||||
|
html,
|
||||||
|
privateField = false,
|
||||||
|
rows = 7
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
inputLabel?: string;
|
||||||
|
hint?: string;
|
||||||
|
emptyText: string;
|
||||||
|
value: string;
|
||||||
|
html: string;
|
||||||
|
privateField?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class:private-field={privateField} class:empty={!value.trim()} class="editorial-field">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>{label}</h3>
|
||||||
|
{#if hint}<p>{hint}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-button editorial-edit-button"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={editing}
|
||||||
|
onclick={() => (editing = !editing)}
|
||||||
|
>
|
||||||
|
{editing ? 'Lesen' : value.trim() ? 'Bearbeiten' : 'Schreiben'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<label class="editorial-input">
|
||||||
|
<span class="sr-only">{inputLabel}</span>
|
||||||
|
<textarea aria-label={inputLabel} {name} {rows} maxlength="50000" bind:value></textarea>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{#if value.trim()}
|
||||||
|
<MarkdownPreview {html} />
|
||||||
|
{:else}
|
||||||
|
<button class="editorial-empty" type="button" onclick={() => (editing = true)}>
|
||||||
|
<span>{emptyText}</span><small>Zum Schreiben öffnen</small>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -12,9 +12,9 @@ export const actions = {
|
|||||||
capture: async ({ locals, request }) => {
|
capture: async ({ locals, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
let id: string;
|
||||||
try {
|
try {
|
||||||
const id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
|
id = captureInbox(String(data.get('title') || ''), String(data.get('url') || ''));
|
||||||
redirect(303, `/fundus/${id}`);
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const code = cause instanceof Error ? cause.message : '';
|
const code = cause instanceof Error ? cause.message : '';
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
@@ -26,5 +26,6 @@ export const actions = {
|
|||||||
: 'Ein Titel ist erforderlich.'
|
: 'Ein Titel ist erforderlich.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
redirect(303, `/fundus/${id}`);
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
+519
-292
@@ -2,10 +2,13 @@
|
|||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, tick, untrack } from 'svelte';
|
||||||
|
import EditorialMarkdownField from '#lib/components/EditorialMarkdownField.svelte';
|
||||||
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
|
import MarkdownPreview from '#lib/components/MarkdownPreview.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
type ContextPanel = 'sources' | 'classification' | 'presentation';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let draft = $state(untrack(() => ({ ...data.piece })));
|
let draft = $state(untrack(() => ({ ...data.piece })));
|
||||||
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2))));
|
||||||
@@ -14,6 +17,10 @@
|
|||||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||||
let saveMessage = $state('Alle Änderungen gespeichert');
|
let saveMessage = $state('Alle Änderungen gespeichert');
|
||||||
|
let classificationEditing = $state(false);
|
||||||
|
let presentationEditing = $state(false);
|
||||||
|
let mobileContext = $state<ContextPanel | null>(null);
|
||||||
|
let sourceImageUnavailable = $state(false);
|
||||||
let editorForm: HTMLFormElement;
|
let editorForm: HTMLFormElement;
|
||||||
let previewForm: HTMLFormElement;
|
let previewForm: HTMLFormElement;
|
||||||
let saveButton: HTMLButtonElement;
|
let saveButton: HTMLButtonElement;
|
||||||
@@ -26,8 +33,27 @@
|
|||||||
saveState === 'error' ||
|
saveState === 'error' ||
|
||||||
saveState === 'conflict'
|
saveState === 'conflict'
|
||||||
);
|
);
|
||||||
|
let featuredSource = $derived(
|
||||||
|
draft.presentationSourcePosition === null
|
||||||
|
? (sources[0] ?? null)
|
||||||
|
: (sources[draft.presentationSourcePosition] ?? sources[0] ?? null)
|
||||||
|
);
|
||||||
|
let sourcePreviewImage = $derived(
|
||||||
|
draft.presentationImageUrl
|
||||||
|
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
||||||
|
: data.piece.media[0]
|
||||||
|
? `/media/${data.piece.media[0].id}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
let pendingHref: string | null = null;
|
let pendingHref: string | null = null;
|
||||||
let allowNavigation = false;
|
let allowNavigation = false;
|
||||||
|
let contextTrigger: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
inbox: 'Eingang',
|
||||||
|
preparing: 'In Vorbereitung',
|
||||||
|
ready: 'Sendefertig'
|
||||||
|
};
|
||||||
|
|
||||||
onDestroy(() => clearTimeout(timer));
|
onDestroy(() => clearTimeout(timer));
|
||||||
beforeNavigate((navigation) => {
|
beforeNavigate((navigation) => {
|
||||||
@@ -87,11 +113,29 @@
|
|||||||
draft.presentationMetadataFetchedAtUtc = null;
|
draft.presentationMetadataFetchedAtUtc = null;
|
||||||
draft.presentationProvider = '';
|
draft.presentationProvider = '';
|
||||||
draft.presentationProviderId = '';
|
draft.presentationProviderId = '';
|
||||||
|
sourceImageUnavailable = false;
|
||||||
if (!draft.presentationTitleManual) draft.presentationTitle = '';
|
if (!draft.presentationTitleManual) draft.presentationTitle = '';
|
||||||
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
|
if (!draft.presentationDescriptionManual) draft.presentationDescription = '';
|
||||||
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
|
if (!draft.presentationImageManual) draft.presentationImageUrl = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openContext(event: MouseEvent, panel: ContextPanel) {
|
||||||
|
contextTrigger = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
|
mobileContext = panel;
|
||||||
|
await tick();
|
||||||
|
document.querySelector<HTMLElement>(`[data-context-close="${panel}"]`)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContext() {
|
||||||
|
mobileContext = null;
|
||||||
|
contextTrigger?.focus();
|
||||||
|
contextTrigger = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && mobileContext) closeContext();
|
||||||
|
}
|
||||||
|
|
||||||
const enhanceSave: SubmitFunction = () => {
|
const enhanceSave: SubmitFunction = () => {
|
||||||
const submittedGeneration = generation;
|
const submittedGeneration = generation;
|
||||||
saving = true;
|
saving = true;
|
||||||
@@ -132,22 +176,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
<svelte:head><title>{draft.title} | Fundus | Sendewerk</title></svelte:head>
|
||||||
|
<svelte:window onkeydown={handleEscape} />
|
||||||
|
|
||||||
<div class="page-wrap editor-page">
|
<div class="page-wrap editor-page worksheet-page">
|
||||||
<header class="editor-header">
|
|
||||||
<div>
|
|
||||||
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
|
|
||||||
<h1>{draft.title}</h1>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class:problem={saveState === 'error' || saveState === 'conflict'}
|
|
||||||
class="save-indicator"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<i></i><span>{saveMessage}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -155,297 +186,493 @@
|
|||||||
use:enhance={enhanceSave}
|
use:enhance={enhanceSave}
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/save"
|
action="?/save"
|
||||||
class="piece-editor"
|
class="piece-editor worksheet-editor"
|
||||||
oninput={scheduleSave}
|
oninput={scheduleSave}
|
||||||
onchange={scheduleSave}
|
onchange={scheduleSave}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} />
|
||||||
<section class="editor-section editor-basics">
|
<header class="worksheet-header">
|
||||||
<div class="section-title">
|
<div class="worksheet-heading">
|
||||||
<p class="eyebrow">Grundlagen</p>
|
<a class="back-link" href={resolve('/fundus')}>← Fundus</a>
|
||||||
<h2>Worum geht es?</h2>
|
<h1 class="sr-only">{draft.title}</h1>
|
||||||
|
<label class="worksheet-title">
|
||||||
|
<span class="sr-only">Titel</span>
|
||||||
|
<textarea
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
rows="1"
|
||||||
|
maxlength="200"
|
||||||
|
aria-label="Titel"
|
||||||
|
bind:value={draft.title}></textarea>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-fields two-columns">
|
<div
|
||||||
<label class="wide-field"
|
class:problem={saveState === 'error' || saveState === 'conflict'}
|
||||||
>Titel<input name="title" required maxlength="200" bind:value={draft.title} /></label
|
class="save-indicator"
|
||||||
>
|
role="status"
|
||||||
<label
|
aria-live="polite"
|
||||||
>Status<select name="status" bind:value={draft.status}
|
>
|
||||||
><option value="inbox">Eingang</option><option value="preparing">In Vorbereitung</option
|
<i></i><span>{saveMessage}</span>
|
||||||
><option value="ready">Sendefertig</option></select
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Format<select name="formatId" bind:value={draft.formatId}
|
|
||||||
><option value={null}>Ohne Format</option
|
|
||||||
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
|
|
||||||
>{item.name}</option
|
|
||||||
>{/each}</select
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Dauer in Minuten<input
|
|
||||||
name="durationMinutes"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1440"
|
|
||||||
step="any"
|
|
||||||
bind:value={durationMinutes}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label class="wide-field"
|
|
||||||
>Kurzbeschreibung<textarea
|
|
||||||
name="summary"
|
|
||||||
rows="2"
|
|
||||||
maxlength="2000"
|
|
||||||
bind:value={draft.summary}></textarea></label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</header>
|
||||||
|
|
||||||
<section class="editor-section taxonomy-editor">
|
<nav class="mobile-editor-tools" aria-label="Piece-Werkzeuge">
|
||||||
<div class="section-title">
|
<button
|
||||||
<p class="eyebrow">Einordnung</p>
|
type="button"
|
||||||
<h2>Themen und Abschnitte</h2>
|
aria-expanded={mobileContext === 'sources'}
|
||||||
</div>
|
onclick={(event) => openContext(event, 'sources')}>Quellen</button
|
||||||
<fieldset>
|
>
|
||||||
<legend>Themen</legend>
|
<button
|
||||||
<div class="choice-grid">
|
type="button"
|
||||||
{#each data.taxonomy.topics as item (item.id)}<label
|
aria-expanded={mobileContext === 'classification'}
|
||||||
><input
|
onclick={(event) => openContext(event, 'classification')}>Einordnung</button
|
||||||
type="checkbox"
|
>
|
||||||
name="topicIds"
|
<button
|
||||||
value={item.id}
|
type="button"
|
||||||
bind:group={draft.topicIds}
|
aria-expanded={mobileContext === 'presentation'}
|
||||||
/>{item.name}</label
|
onclick={(event) => openContext(event, 'presentation')}>Präsentation</button
|
||||||
>{/each}
|
>
|
||||||
</div>
|
<a href="#piece-media">Bilder</a>
|
||||||
</fieldset>
|
</nav>
|
||||||
<fieldset>
|
|
||||||
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
|
||||||
<div class="choice-grid">
|
|
||||||
{#each data.taxonomy.categories as item (item.id)}<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
name="categoryIds"
|
|
||||||
value={item.id}
|
|
||||||
bind:group={draft.categoryIds}
|
|
||||||
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
|
|
||||||
>{/each}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section markdown-section">
|
<div class="editor-workspace">
|
||||||
<div class="section-title">
|
<main class="editorial-worksheet" aria-label="Redaktionelles Arbeitsblatt">
|
||||||
<p class="eyebrow">Redaktion</p>
|
<section
|
||||||
<h2>Getrennte Arbeitsbereiche</h2>
|
class:has-image={sourcePreviewImage && !sourceImageUnavailable}
|
||||||
<small>Markdown wird unterstützt und vor der Anzeige bereinigt.</small>
|
class="source-feature"
|
||||||
</div>
|
|
||||||
<div class="markdown-grid">
|
|
||||||
<label
|
|
||||||
><span>Einleitung</span><textarea
|
|
||||||
name="intro"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.intro}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.intro} /></label
|
|
||||||
>
|
>
|
||||||
<label class="private-field"
|
{#if sourcePreviewImage && !sourceImageUnavailable}
|
||||||
><span>Private Recherche</span><textarea
|
<img src={sourcePreviewImage} alt="" onerror={() => (sourceImageUnavailable = true)} />
|
||||||
name="researchText"
|
{/if}
|
||||||
rows="7"
|
<div>
|
||||||
maxlength="50000"
|
<p class="eyebrow">Fundstück</p>
|
||||||
bind:value={draft.researchText}></textarea>
|
{#if featuredSource}
|
||||||
<MarkdownPreview html={rendered.research} /></label
|
<h2>{draft.presentationTitle || featuredSource.label || 'Quelle'}</h2>
|
||||||
>
|
{#if draft.presentationDescription}<p>{draft.presentationDescription}</p>{/if}
|
||||||
<label
|
<small>{featuredSource.label || featuredSource.url}</small>
|
||||||
><span>Moderationsstichpunkte</span><textarea
|
<!-- The URL is a server-validated external HTTP(S) source. -->
|
||||||
name="moderationNotes"
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
rows="7"
|
<a href={featuredSource.url} target="_blank" rel="noopener noreferrer"
|
||||||
maxlength="50000"
|
>Original öffnen ↗</a
|
||||||
bind:value={draft.moderationNotes}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.moderation} /></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
><span>Überleitung</span><textarea
|
|
||||||
name="transitionText"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.transitionText}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.transition} /></label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section presentation-editor">
|
|
||||||
<div class="section-title split-section-title">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Präsentation</p>
|
|
||||||
<h2>Darstellung in heinzlive</h2>
|
|
||||||
<small>Nur die ausdrücklich ausgewählte Quelle wird präsentiert.</small>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
class="secondary-button"
|
|
||||||
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener">Präsentation testen</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
|
||||||
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="radio"
|
|
||||||
name="presentationMode"
|
|
||||||
value={mode[0]}
|
|
||||||
bind:group={draft.presentationMode}
|
|
||||||
/>{mode[1]}</label
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if draft.presentationMode === 'text'}
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationSourcePosition"
|
|
||||||
value={draft.presentationSourcePosition ?? ''}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
|
|
||||||
<input type="hidden" name="presentationDescription" value={draft.presentationDescription} />
|
|
||||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
|
||||||
<label class="presentation-text-field"
|
|
||||||
><span>Präsentationstext</span><textarea
|
|
||||||
name="presentationText"
|
|
||||||
rows="7"
|
|
||||||
maxlength="50000"
|
|
||||||
bind:value={draft.presentationText}></textarea>
|
|
||||||
<MarkdownPreview html={rendered.presentation} /></label
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<input type="hidden" name="presentationText" value={draft.presentationText} />
|
|
||||||
<div class="presentation-settings">
|
|
||||||
<label class="wide-field"
|
|
||||||
>Quelle<select
|
|
||||||
name="presentationSourcePosition"
|
|
||||||
bind:value={draft.presentationSourcePosition}
|
|
||||||
onchange={presentationSourceChanged}
|
|
||||||
>
|
|
||||||
<option value={null}>Quelle auswählen</option>
|
|
||||||
{#each sources as source, index (index)}
|
|
||||||
{#if source.url}<option value={index}>{source.label || source.url}</option>{/if}
|
|
||||||
{/each}
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Titel<input
|
|
||||||
name="presentationTitle"
|
|
||||||
maxlength="300"
|
|
||||||
bind:value={draft.presentationTitle}
|
|
||||||
oninput={() => (draft.presentationTitleManual = true)}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
>Bild-URL<input
|
|
||||||
name="presentationImageUrl"
|
|
||||||
type="url"
|
|
||||||
maxlength="2000"
|
|
||||||
bind:value={draft.presentationImageUrl}
|
|
||||||
oninput={() => (draft.presentationImageManual = true)}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
<label class="wide-field"
|
|
||||||
>Anreißer<textarea
|
|
||||||
name="presentationDescription"
|
|
||||||
rows="3"
|
|
||||||
maxlength="1000"
|
|
||||||
bind:value={draft.presentationDescription}
|
|
||||||
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
|
|
||||||
>
|
|
||||||
<div class="presentation-preview-actions wide-field">
|
|
||||||
<button
|
|
||||||
class="secondary-button"
|
|
||||||
type="button"
|
|
||||||
onclick={() => previewForm.requestSubmit()}
|
|
||||||
disabled={actionsBlocked || draft.presentationSourcePosition === null}
|
|
||||||
>Vorschau laden</button
|
|
||||||
>
|
|
||||||
<small>
|
|
||||||
{draft.presentationMetadataFetchedAtUtc
|
|
||||||
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
|
|
||||||
: 'Noch keine Metadaten geladen.'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationTitleManual"
|
|
||||||
value={draft.presentationTitleManual ? '1' : '0'}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationDescriptionManual"
|
|
||||||
value={draft.presentationDescriptionManual ? '1' : '0'}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationImageManual"
|
|
||||||
value={draft.presentationImageManual ? '1' : '0'}
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
|
||||||
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
|
|
||||||
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="presentationMetadataFetchedAtUtc"
|
|
||||||
value={draft.presentationMetadataFetchedAtUtc ?? ''}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-section source-editor">
|
|
||||||
<div class="section-title split-section-title">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Belege</p>
|
|
||||||
<h2>Quellen</h2>
|
|
||||||
</div>
|
|
||||||
<button class="secondary-button" type="button" onclick={addSource}>Quelle hinzufügen</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if sources.length}<div class="source-list">
|
|
||||||
{#each sources as source, index (index)}<div class="source-row">
|
|
||||||
<label
|
|
||||||
>Name<input name="sourceLabel" maxlength="200" bind:value={source.label} /></label
|
|
||||||
><label
|
|
||||||
>URL<input
|
|
||||||
name="sourceUrl"
|
|
||||||
type="url"
|
|
||||||
maxlength="2000"
|
|
||||||
bind:value={source.url}
|
|
||||||
/></label
|
|
||||||
><label
|
|
||||||
>Zeitmarke in Sekunden<input
|
|
||||||
name="sourceTimecode"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="86400"
|
|
||||||
bind:value={source.timecodeSeconds}
|
|
||||||
/></label
|
|
||||||
><button
|
|
||||||
class="text-button danger-text"
|
|
||||||
type="button"
|
|
||||||
onclick={() => removeSource(index)}>Entfernen</button
|
|
||||||
>
|
>
|
||||||
</div>{/each}
|
{:else}
|
||||||
</div>{:else}<p class="quiet-copy">Noch keine Quellen hinterlegt.</p>{/if}
|
<h2>Noch keine Quelle</h2>
|
||||||
</section>
|
<p>Eine Quelle lässt sich ergänzen, ohne den Schreibstand zu verlassen.</p>
|
||||||
|
<button
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => openContext(event, 'sources')}>Quelle ergänzen</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="writing-sheet">
|
||||||
|
<header class="writing-sheet-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Arbeitsstand</p>
|
||||||
|
<h2>Gedanken und Sendungstext</h2>
|
||||||
|
</div>
|
||||||
|
<small>Markdown wird beim Speichern sicher formatiert.</small>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EditorialMarkdownField
|
||||||
|
name="researchText"
|
||||||
|
label="Eigene Gedanken & Recherche"
|
||||||
|
inputLabel="Private Recherche"
|
||||||
|
hint="Nur intern"
|
||||||
|
emptyText="Beobachtungen, Links und offene Fragen festhalten"
|
||||||
|
bind:value={draft.researchText}
|
||||||
|
html={rendered.research}
|
||||||
|
privateField
|
||||||
|
rows={9}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="script-heading">
|
||||||
|
<p class="eyebrow">Für die Sendung</p>
|
||||||
|
<h2>Sendungstext</h2>
|
||||||
|
<p>Einleitung, Moderation und Übergang bleiben getrennt gespeichert.</p>
|
||||||
|
</div>
|
||||||
|
<div class="script-sections">
|
||||||
|
<EditorialMarkdownField
|
||||||
|
name="intro"
|
||||||
|
label="Einleitung"
|
||||||
|
emptyText="Wie beginnt dieses Thema in der Sendung?"
|
||||||
|
bind:value={draft.intro}
|
||||||
|
html={rendered.intro}
|
||||||
|
/>
|
||||||
|
<EditorialMarkdownField
|
||||||
|
name="moderationNotes"
|
||||||
|
label="Moderationsstichpunkte"
|
||||||
|
emptyText="Die wichtigsten Punkte für die Moderation notieren"
|
||||||
|
bind:value={draft.moderationNotes}
|
||||||
|
html={rendered.moderation}
|
||||||
|
/>
|
||||||
|
<EditorialMarkdownField
|
||||||
|
name="transitionText"
|
||||||
|
label="Überleitung"
|
||||||
|
emptyText="Den Übergang zum nächsten Thema vorbereiten"
|
||||||
|
bind:value={draft.transitionText}
|
||||||
|
html={rendered.transition}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class:context-open={mobileContext}
|
||||||
|
class="editor-context"
|
||||||
|
aria-label="Werkzeuge zum Piece"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class:mobile-active={mobileContext === 'sources'}
|
||||||
|
class="context-card source-editor"
|
||||||
|
aria-labelledby="source-context-title"
|
||||||
|
>
|
||||||
|
<header class="context-card-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Fundstück</p>
|
||||||
|
<h2 id="source-context-title">Quellen</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="context-close"
|
||||||
|
data-context-close="sources"
|
||||||
|
type="button"
|
||||||
|
aria-label="Quellen schließen"
|
||||||
|
onclick={closeContext}>×</button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
{#if sources.length}
|
||||||
|
<div class="source-list">
|
||||||
|
{#each sources as source, index (index)}
|
||||||
|
<div class="source-row">
|
||||||
|
<label
|
||||||
|
>Name<input
|
||||||
|
name="sourceLabel"
|
||||||
|
maxlength="200"
|
||||||
|
bind:value={source.label}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>URL<input
|
||||||
|
name="sourceUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
bind:value={source.url}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Zeitmarke in Sekunden<input
|
||||||
|
name="sourceTimecode"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="86400"
|
||||||
|
bind:value={source.timecodeSeconds}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-button danger-text"
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeSource(index)}>Entfernen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="context-empty">Noch keine Quelle hinterlegt.</p>
|
||||||
|
{/if}
|
||||||
|
<button class="secondary-button context-wide-action" type="button" onclick={addSource}
|
||||||
|
>Quelle hinzufügen</button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class:mobile-active={mobileContext === 'classification'}
|
||||||
|
class="context-card classification-editor"
|
||||||
|
aria-labelledby="classification-context-title"
|
||||||
|
>
|
||||||
|
<header class="context-card-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Überblick</p>
|
||||||
|
<h2 id="classification-context-title">Einordnung</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="context-close"
|
||||||
|
data-context-close="classification"
|
||||||
|
type="button"
|
||||||
|
aria-label="Einordnung schließen"
|
||||||
|
onclick={closeContext}>×</button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<div class="classification-summary">
|
||||||
|
<strong>{statusLabels[draft.status as keyof typeof statusLabels]}</strong>
|
||||||
|
<span>{durationMinutes || 0} Min.</span>
|
||||||
|
<span
|
||||||
|
>{data.taxonomy.formats.find((item) => item.id === draft.formatId)?.name ??
|
||||||
|
'Ohne Format'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if draft.summary}<p class="context-summary-copy">{draft.summary}</p>{/if}
|
||||||
|
<div class="context-tags">
|
||||||
|
{#each data.taxonomy.topics.filter( (item) => draft.topicIds.includes(item.id) ) as item (item.id)}<span
|
||||||
|
>{item.name}</span
|
||||||
|
>{/each}
|
||||||
|
{#each data.taxonomy.categories.filter( (item) => draft.categoryIds.includes(item.id) ) as item (item.id)}<span
|
||||||
|
>{item.name}</span
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-button context-edit-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={classificationEditing}
|
||||||
|
onclick={() => (classificationEditing = !classificationEditing)}
|
||||||
|
>{classificationEditing ? 'Einordnung schließen' : 'Einordnung bearbeiten'}</button
|
||||||
|
>
|
||||||
|
<div class="context-fields" hidden={!classificationEditing}>
|
||||||
|
<label
|
||||||
|
>Status<select name="status" bind:value={draft.status}
|
||||||
|
><option value="inbox">Eingang</option><option value="preparing"
|
||||||
|
>In Vorbereitung</option
|
||||||
|
><option value="ready">Sendefertig</option></select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Dauer in Minuten<input
|
||||||
|
name="durationMinutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1440"
|
||||||
|
step="any"
|
||||||
|
bind:value={durationMinutes}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Format<select name="formatId" bind:value={draft.formatId}
|
||||||
|
><option value={null}>Ohne Format</option
|
||||||
|
>{#each data.taxonomy.formats as item (item.id)}<option value={item.id}
|
||||||
|
>{item.name}</option
|
||||||
|
>{/each}</select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Kurzbeschreibung<textarea
|
||||||
|
name="summary"
|
||||||
|
rows="3"
|
||||||
|
maxlength="2000"
|
||||||
|
bind:value={draft.summary}></textarea></label
|
||||||
|
>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Themen</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.topics as item (item.id)}<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="topicIds"
|
||||||
|
value={item.id}
|
||||||
|
bind:group={draft.topicIds}
|
||||||
|
/>{item.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
||||||
|
<div class="choice-grid">
|
||||||
|
{#each data.taxonomy.categories as item (item.id)}<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="categoryIds"
|
||||||
|
value={item.id}
|
||||||
|
bind:group={draft.categoryIds}
|
||||||
|
/><i style={`--choice-color:${item.color}`}></i>{item.name}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class:mobile-active={mobileContext === 'presentation'}
|
||||||
|
class="context-card presentation-editor"
|
||||||
|
aria-labelledby="presentation-context-title"
|
||||||
|
>
|
||||||
|
<header class="context-card-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Publikum</p>
|
||||||
|
<h2 id="presentation-context-title">Präsentation</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="context-close"
|
||||||
|
data-context-close="presentation"
|
||||||
|
type="button"
|
||||||
|
aria-label="Präsentation schließen"
|
||||||
|
onclick={closeContext}>×</button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<div class="audience-preview">
|
||||||
|
<span
|
||||||
|
>{draft.presentationMode === 'text'
|
||||||
|
? 'Text'
|
||||||
|
: draft.presentationMode === 'link'
|
||||||
|
? 'Linkvorschau'
|
||||||
|
: 'Video'}</span
|
||||||
|
>
|
||||||
|
<strong>{draft.presentationTitle || draft.title}</strong>
|
||||||
|
{#if draft.presentationMode === 'text' && rendered.presentation}<MarkdownPreview
|
||||||
|
html={rendered.presentation}
|
||||||
|
/>{:else if draft.presentationDescription}<p>
|
||||||
|
{draft.presentationDescription}
|
||||||
|
</p>{:else}<p>Noch keine Vorschauangaben.</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="presentation-card-actions">
|
||||||
|
<button
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={presentationEditing}
|
||||||
|
onclick={() => (presentationEditing = !presentationEditing)}
|
||||||
|
>{presentationEditing ? 'Einstellungen schließen' : 'Präsentation bearbeiten'}</button
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="secondary-button"
|
||||||
|
href={resolve('/fundus/[id]/praesentation', { id: data.piece.id })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">Präsentation testen</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="presentation-controls" hidden={!presentationEditing}>
|
||||||
|
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
|
||||||
|
{#each [['text', 'Text'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="presentationMode"
|
||||||
|
value={mode[0]}
|
||||||
|
bind:group={draft.presentationMode}
|
||||||
|
/>{mode[1]}</label
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
{#if draft.presentationMode === 'text'}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationSourcePosition"
|
||||||
|
value={draft.presentationSourcePosition ?? ''}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="presentationTitle" value={draft.presentationTitle} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationDescription"
|
||||||
|
value={draft.presentationDescription}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||||
|
<EditorialMarkdownField
|
||||||
|
name="presentationText"
|
||||||
|
label="Präsentationstext"
|
||||||
|
emptyText="Text für die Publikumsausgabe schreiben"
|
||||||
|
bind:value={draft.presentationText}
|
||||||
|
html={rendered.presentation}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="presentationText" value={draft.presentationText} />
|
||||||
|
<div class="presentation-settings">
|
||||||
|
<label
|
||||||
|
>Quelle<select
|
||||||
|
name="presentationSourcePosition"
|
||||||
|
bind:value={draft.presentationSourcePosition}
|
||||||
|
onchange={presentationSourceChanged}
|
||||||
|
><option value={null}>Quelle auswählen</option
|
||||||
|
>{#each sources as source, index (index)}{#if source.url}<option value={index}
|
||||||
|
>{source.label || source.url}</option
|
||||||
|
>{/if}{/each}</select
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Titel<input
|
||||||
|
name="presentationTitle"
|
||||||
|
maxlength="300"
|
||||||
|
bind:value={draft.presentationTitle}
|
||||||
|
oninput={() => (draft.presentationTitleManual = true)}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Bild-URL<input
|
||||||
|
name="presentationImageUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2000"
|
||||||
|
bind:value={draft.presentationImageUrl}
|
||||||
|
oninput={() => (draft.presentationImageManual = true)}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Anreißer<textarea
|
||||||
|
name="presentationDescription"
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
bind:value={draft.presentationDescription}
|
||||||
|
oninput={() => (draft.presentationDescriptionManual = true)}></textarea></label
|
||||||
|
>
|
||||||
|
<div class="presentation-preview-actions">
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => previewForm.requestSubmit()}
|
||||||
|
disabled={actionsBlocked || draft.presentationSourcePosition === null}
|
||||||
|
>Vorschau laden</button
|
||||||
|
><small
|
||||||
|
>{draft.presentationMetadataFetchedAtUtc
|
||||||
|
? `Zuletzt geladen: ${new Date(draft.presentationMetadataFetchedAtUtc).toLocaleString('de-DE')}`
|
||||||
|
: 'Noch keine Metadaten geladen.'}</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationTitleManual"
|
||||||
|
value={draft.presentationTitleManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationDescriptionManual"
|
||||||
|
value={draft.presentationDescriptionManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationImageManual"
|
||||||
|
value={draft.presentationImageManual ? '1' : '0'}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||||
|
<input type="hidden" name="presentationProvider" value={draft.presentationProvider} />
|
||||||
|
<input type="hidden" name="presentationProviderId" value={draft.presentationProviderId} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="presentationMetadataFetchedAtUtc"
|
||||||
|
value={draft.presentationMetadataFetchedAtUtc ?? ''}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="context-card asset-summary">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Material</p>
|
||||||
|
<h2>Bilder</h2>
|
||||||
|
<p>
|
||||||
|
{data.piece.media.length
|
||||||
|
? `${data.piece.media.length} gespeichert`
|
||||||
|
: 'Noch keine Bilder'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="#piece-media">Bilder verwalten ↓</a>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="editor-savebar">
|
<div class="editor-savebar">
|
||||||
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span
|
<span class:problem={saveState === 'error' || saveState === 'conflict'}>{saveMessage}</span>
|
||||||
><button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
|
<button bind:this={saveButton} class="primary-button" type="submit" disabled={saving}
|
||||||
>Jetzt speichern</button
|
>Jetzt speichern</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
bind:this={previewForm}
|
bind:this={previewForm}
|
||||||
id="preview-load-form"
|
id="preview-load-form"
|
||||||
@@ -456,7 +683,7 @@
|
|||||||
<input type="hidden" name="revision" value={revision} />
|
<input type="hidden" name="revision" value={revision} />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="editor-section media-editor">
|
<section id="piece-media" class="editor-section media-editor supporting-editor-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<p class="eyebrow">Private Dateien</p>
|
<p class="eyebrow">Private Dateien</p>
|
||||||
<h2>Bilder</h2>
|
<h2>Bilder</h2>
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
|
|||||||
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEditorialField(page: Page, heading: string) {
|
||||||
|
const field = page.locator('.editorial-field').filter({
|
||||||
|
has: page.getByRole('heading', { name: heading, exact: true })
|
||||||
|
});
|
||||||
|
await field.locator('.editorial-edit-button').click();
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -1014,13 +1022,20 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await expect(page).toHaveURL(/\/fundus$/);
|
await expect(page).toHaveURL(/\/fundus$/);
|
||||||
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
|
await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click();
|
||||||
await expect(page).toHaveURL(pieceUrl);
|
await expect(page).toHaveURL(pieceUrl);
|
||||||
|
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
|
||||||
await page
|
await page
|
||||||
.getByLabel('Kurzbeschreibung')
|
.getByLabel('Kurzbeschreibung')
|
||||||
.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.getByLabel('Gaming').check();
|
await page.getByLabel('Gaming').check();
|
||||||
|
await openEditorialField(page, 'Eigene Gedanken & Recherche');
|
||||||
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
|
await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.');
|
||||||
|
await openEditorialField(page, 'Moderationsstichpunkte');
|
||||||
|
await page.getByLabel('Moderationsstichpunkte').fill('Den Kern in zwei Sätzen erklären.');
|
||||||
|
await expect(page.getByLabel('Private Recherche')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
|
||||||
|
await openEditorialField(page, 'Präsentationstext');
|
||||||
await page
|
await page
|
||||||
.getByLabel('Präsentationstext')
|
.getByLabel('Präsentationstext')
|
||||||
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
|
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**');
|
||||||
@@ -1032,11 +1047,18 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
await page.waitForTimeout(1_200);
|
await page.waitForTimeout(1_200);
|
||||||
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
|
||||||
|
await page
|
||||||
|
.locator('.editorial-field')
|
||||||
|
.filter({ has: page.getByRole('heading', { name: 'Präsentationstext', exact: true }) })
|
||||||
|
.getByRole('button', { name: 'Lesen' })
|
||||||
|
.click();
|
||||||
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
|
await expect(page.locator('.markdown-preview script')).toHaveCount(0);
|
||||||
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
|
await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
|
await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown');
|
||||||
|
await page.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
|
||||||
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
|
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
|
||||||
|
await openEditorialField(page, 'Eigene Gedanken & Recherche');
|
||||||
await expect(page.getByLabel('Private Recherche')).toHaveValue(
|
await expect(page.getByLabel('Private Recherche')).toHaveValue(
|
||||||
'Nur intern: **Hintergrund prüfen**.'
|
'Nur intern: **Hintergrund prüfen**.'
|
||||||
);
|
);
|
||||||
@@ -1067,6 +1089,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
await concurrent.goto(pieceUrl);
|
await concurrent.goto(pieceUrl);
|
||||||
await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert');
|
await page.getByLabel('Titel').fill('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.getByRole('button', { name: 'Einordnung bearbeiten' }).click();
|
||||||
await concurrent
|
await concurrent
|
||||||
.getByLabel('Kurzbeschreibung')
|
.getByLabel('Kurzbeschreibung')
|
||||||
.fill('Diese veraltete Änderung darf nicht gewinnen.');
|
.fill('Diese veraltete Änderung darf nicht gewinnen.');
|
||||||
@@ -1075,6 +1098,7 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
});
|
});
|
||||||
await concurrent.reload();
|
await concurrent.reload();
|
||||||
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
|
await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert');
|
||||||
|
await concurrent.getByRole('button', { name: 'Einordnung bearbeiten' }).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.'
|
||||||
);
|
);
|
||||||
@@ -1099,9 +1123,21 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
'Kopie von E2E Fundstück, extern aktualisiert'
|
'Kopie von E2E Fundstück, extern aktualisiert'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.getByLabel('Titel').fill('Kopie mit unterbrochener Verbindung');
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
|
||||||
|
timeout: 10_000
|
||||||
|
});
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.getByRole('button', { name: 'Jetzt speichern' }).click();
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await setTheme(page, 'dark');
|
await setTheme(page, 'dark');
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Quellen', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Quellen schließen' }).click();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
|
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
|
||||||
@@ -1109,16 +1145,70 @@ test('pieces autosave safely, detect conflicts and protect uploaded images', asy
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a quickly captured video can be written and previewed before classification', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><title>YouTube Test</title>'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.goto('/eingang');
|
||||||
|
await page.getByLabel('Titel oder Gedanke').fill('Schnelles Video-Fundstück');
|
||||||
|
await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ');
|
||||||
|
await page.getByRole('button', { name: 'Aufnehmen und öffnen' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
|
||||||
|
await expect(page.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ'
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Status')).toBeHidden();
|
||||||
|
|
||||||
|
await openEditorialField(page, 'Eigene Gedanken & Recherche');
|
||||||
|
await page
|
||||||
|
.getByLabel('Private Recherche')
|
||||||
|
.fill('Warum funktioniert der Einstieg so unmittelbar?');
|
||||||
|
await openEditorialField(page, 'Moderationsstichpunkte');
|
||||||
|
await page
|
||||||
|
.getByLabel('Moderationsstichpunkte')
|
||||||
|
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
|
||||||
|
await expect(page.getByLabel('Private Recherche')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
|
||||||
|
await page.getByLabel('Video').check();
|
||||||
|
await page.getByRole('combobox', { name: 'Quelle', exact: true }).selectOption('0');
|
||||||
|
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
|
||||||
|
|
||||||
|
const previewPromise = page.waitForEvent('popup');
|
||||||
|
await page.getByRole('link', { name: 'Präsentation testen' }).click();
|
||||||
|
const preview = await previewPromise;
|
||||||
|
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
|
||||||
|
);
|
||||||
|
await preview.close();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
|
||||||
|
await expect(page.getByLabel('Video')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Präsentation schließen' }).click();
|
||||||
|
await expect(page.getByLabel('Private Recherche')).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
|
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
|
||||||
page
|
page
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.goto('/fundus/piece-console');
|
await page.goto('/fundus/piece-console');
|
||||||
|
await page.getByRole('button', { name: 'Präsentation bearbeiten' }).click();
|
||||||
await page.getByLabel('Linkvorschau').check();
|
await page.getByLabel('Linkvorschau').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('Titel', { exact: true }).last().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 bearbeiten' }).click();
|
||||||
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
|
await expect(page.getByLabel('Linkvorschau')).toBeChecked();
|
||||||
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
|
await expect(page.getByLabel('Titel', { exact: true }).last()).toHaveValue(
|
||||||
'Manuell korrigierte Linkvorschau'
|
'Manuell korrigierte Linkvorschau'
|
||||||
|
|||||||
Reference in new issue
Block a user