feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+507
View File
@@ -0,0 +1,507 @@
"use client";
import { useActionState, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, Lock, Save, Send, TriangleAlert } from "lucide-react";
import { AbschnittKarte } from "@/components/vorlagen/editor-abschnitt";
import { Kennzeichen, TextKnopf } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile } from "@/components/vorlagen/editor-sortieren";
import { VariablenBereich } from "@/components/vorlagen/editor-variablen";
import { PlatzhalterHilfe } from "@/components/vorlagen/platzhalter-hilfe";
import { Vorschau } from "@/components/vorlagen/vorschau";
import {
alsEditorAufbau,
alsStruktur,
neuerAbschnitt,
neuerSchritt,
neueVariable,
umsortieren,
verwendetePlatzhalter,
type EditorAbschnitt,
type EditorAufbau,
type EditorSchritt,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
/**
* Der Vorlagen-Editor (#010).
*
* Haelt den ganzen Aufbau im Zustand und schickt ihn beim Speichern als ein
* Stueck JSON an `entwurfSpeichernAktion`. Kein Autosave: eine halb umgebaute
* Vorlage soll nicht nebenbei zur Wahrheit werden, und ein Speichern, das
* niemand ausgeloest hat, ueberschreibt im Zweifel die Arbeit eines anderen.
*
* Eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt, nur mit
* `lesend`. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht etwas
* anderes zeigt als der Editor.
*/
export type EditorVersion = {
readonly id: string;
readonly vorlageId: string;
readonly vorlageName: string;
readonly version: number;
readonly veroeffentlicht: boolean;
readonly struktur: Struktur;
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string;
readonly aenderungsnotiz: string | null;
};
/**
* Was der Editor ueber das Speichern wissen muss.
*
* Steckt in einem Zustand, weil beides ueber mehrere Versuche hinweg gelten
* muss: der `stand` des letzten erfolgreichen Speicherns und der Aufbau, der
* dabei geschrieben wurde. Nur so kann ein fehlgeschlagener zweiter Versuch
* nicht den Stand des ersten wieder vergessen.
*/
type SpeicherZustand = {
readonly fehler?: string;
readonly erfolg?: string;
readonly stand: string;
readonly gespeichertesJson: string;
};
export function Editor({
version,
lesend,
darfBearbeiten,
beispielDatum,
}: {
version: EditorVersion;
lesend: boolean;
darfBearbeiten: boolean;
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
beispielDatum: string;
}) {
const router = useRouter();
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() => alsEditorAufbau(version.struktur));
const aktuellesJson = useMemo(() => JSON.stringify(alsStruktur(aufbau)), [aufbau]);
/**
* Der geladene Aufbau, einmal durch den Editor gedreht.
*
* Der Vergleichspunkt fuer „nicht gespeichert“ muss dieselbe Reise gemacht
* haben wie der Zustand: ein Titel, der seine mitgespeicherte Nummer verliert,
* ist keine Aenderung des Benutzers. Die Angleichung wandert mit dem naechsten
* Speichern in die Datenbank.
*/
const anfangsJson = useMemo(
() => JSON.stringify(alsStruktur(alsEditorAufbau(version.struktur))),
[version.struktur],
);
/**
* Das Speichern haengt sich um die Server Action und merkt sich zwei Dinge,
* die sie nicht zurueckgibt: den letzten guten `stand` und den Aufbau, der
* damit geschrieben wurde. Der Aufbau kommt aus dem abgeschickten Formular —
* damit ist es genau der, den der Server gesehen hat, und nicht der, an dem
* jemand waehrend der Anfrage weitergeschrieben hat.
*/
async function speichernMitStand(vorher: SpeicherZustand, formular: FormData): Promise<SpeicherZustand> {
const ergebnis = await entwurfSpeichernAktion({}, formular);
const gesendet = formular.get("struktur");
return {
...ergebnis,
stand: ergebnis.stand ?? vorher.stand,
gespeichertesJson: ergebnis.stand && typeof gesendet === "string" ? gesendet : vorher.gespeichertesJson,
};
}
const [speicherErgebnis, speichern, speichertGerade] = useActionState(speichernMitStand, {
stand: version.stand,
gespeichertesJson: anfangsJson,
});
const [freigabeErgebnis, veroeffentlichen, veroeffentlichtGerade] = useActionState(
veroeffentlichenAktion,
{},
);
const nichtGespeichert = aktuellesJson !== speicherErgebnis.gespeichertesJson;
/**
* Die zuletzt angelegte Zeile wird aufgeklappt gezeigt — alles andere bleibt
* zu. Nur die neueste: aeltere Karten haengen laengst an ihrem eigenen
* Zustand, den sie beim Anlegen bekommen haben, und behalten ihn.
*/
const [zuletztAngelegt, setzeZuletztAngelegt] = useState<string | null>(null);
// Nach dem Veroeffentlichen ist diese Version unveraenderlich. Die Seite muss
// neu geladen werden, damit der Editor in den Lesemodus geht; ein
// revalidatePath auf /vorlagen erreicht diese Adresse nicht.
useEffect(() => {
if (freigabeErgebnis.erfolg) router.refresh();
}, [freigabeErgebnis, router]);
/**
* Der Browser fragt vor dem Verlassen nach, solange etwas nicht gespeichert
* ist. Greift nur bei echtem Verlassen der Seite, nicht bei einem Klick in der
* Navigation — deshalb steht das Kennzeichen zusaetzlich sichtbar oben.
*/
useEffect(() => {
if (lesend || !nichtGespeichert) return undefined;
const warnen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault();
window.addEventListener("beforeunload", warnen);
return () => window.removeEventListener("beforeunload", warnen);
}, [lesend, nichtGespeichert]);
const verwendet = verwendetePlatzhalter(aufbau);
const unbekannt = unbekanntePlatzhalter(
verwendet,
aufbau.variablen.map((variable) => variable.schluessel.trim()),
);
const konflikt = speicherErgebnis.fehler?.includes("zwischenzeitlich") === true;
// Abschnitte
function abschnitteSetzen(neue: readonly EditorAbschnitt[]) {
setzeAufbau((alt) => ({ ...alt, abschnitte: neue }));
}
function abschnittAendern(kennung: string, teil: Partial<EditorAbschnitt>) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === kennung ? { ...abschnitt, ...teil } : abschnitt,
),
}));
}
function schritteSetzen(
abschnittKennung: string,
aendere: (schritte: readonly EditorSchritt[]) => readonly EditorSchritt[],
) {
setzeAufbau((alt) => ({
...alt,
abschnitte: alt.abschnitte.map((abschnitt) =>
abschnitt.kennung === abschnittKennung
? { ...abschnitt, schritte: aendere(abschnitt.schritte) }
: abschnitt,
),
}));
}
// Variablen
function variablenSetzen(neue: readonly EditorVariable[]) {
setzeAufbau((alt) => ({ ...alt, variablen: neue }));
}
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3">
<Link
href={`/vorlagen/${version.vorlageId}`}
className="flex min-h-11 items-center gap-2 self-start text-sm text-nebentext hover:text-text md:min-h-10"
>
<ArrowLeft className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
Zur Vorlage
</Link>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold">{version.vorlageName}</h1>
<Kennzeichen>Version {version.version}</Kennzeichen>
<Kennzeichen ton={version.veroeffentlicht ? "akzent" : "still"}>
{version.veroeffentlicht ? "veröffentlicht" : "Entwurf"}
</Kennzeichen>
{lesend ? null : nichtGespeichert ? (
<Kennzeichen ton="faellig">Nicht gespeichert</Kennzeichen>
) : (
<Kennzeichen>gespeichert</Kennzeichen>
)}
</div>
</header>
{lesend ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{version.veroeffentlicht ? (
<>
Diese Version ist veröffentlicht und wird nicht mehr verändert — Läufe hängen daran.{" "}
{darfBearbeiten ? (
<>
„Bearbeiten“ auf der{" "}
<Link
href={`/vorlagen/${version.vorlageId}`}
className="text-akzent underline underline-offset-2"
>
Vorlagenseite
</Link>{" "}
erzeugt daraus einen neuen Entwurf; je Vorlage gibt es höchstens einen.
</>
) : (
"Ein neuer Entwurf entsteht über „Bearbeiten“ auf der Vorlagenseite; dafür braucht es die Rolle Redakteur oder Administrator."
)}
</>
) : (
"Dieser Entwurf lässt sich ansehen, aber nicht ändern — dafür braucht es die Rolle Redakteur oder Administrator."
)}
</p>
</div>
) : null}
{unbekannt.length > 0 ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche p-4">
<TriangleAlert
className="mt-0.5 size-[18px] shrink-0 text-faellig"
strokeWidth={1.75}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-sm text-faellig">
{unbekannt.length === 1
? "Ein Platzhalter gehört zu keiner Variablen und zu keinem eingebauten Wert:"
: `${unbekannt.length} Platzhalter gehören zu keiner Variablen und zu keinem eingebauten Wert:`}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{unbekannt.map((schluessel) => (
<li key={schluessel}>
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
{`{{${schluessel}}}`}
</code>
</li>
))}
</ul>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Im Lauf bleibt ein unbekannter Platzhalter als Text stehen. Speichern und Veröffentlichen sind
trotzdem möglich — vielleicht kommt die Variable ja noch.
</p>
</div>
</div>
) : null}
<VariablenBereich
variablen={aufbau.variablen}
verwendet={verwendet}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
aendern={(kennung, teil) =>
variablenSetzen(
aufbau.variablen.map((variable) =>
variable.kennung === kennung ? { ...variable, ...teil } : variable,
),
)
}
hinzufuegen={() => {
const variable = neueVariable();
setzeZuletztAngelegt(variable.kennung);
variablenSetzen([...aufbau.variablen, variable]);
}}
loeschen={(kennung) =>
variablenSetzen(aufbau.variablen.filter((variable) => variable.kennung !== kennung))
}
umsortieren={(von, nach) => variablenSetzen(umsortieren(aufbau.variablen, von, nach))}
/>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Abschnitte</h2>
{aufbau.abschnitte.length === 0 ? (
<p className="text-sm text-nebentext">
Noch kein Abschnitt. Ein Abschnitt fasst die Schritte zusammen, die zusammengehören.
</p>
) : (
<SortierBereich
kennungen={aufbau.abschnitte.map((abschnitt) => abschnitt.kennung)}
art="Abschnitt"
lesend={lesend}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
>
<ul className="flex flex-col gap-4">
{aufbau.abschnitte.map((abschnitt, index) => (
<SortierZeile key={abschnitt.kennung} kennung={abschnitt.kennung} lesend={lesend}>
{(anfasser) => (
<AbschnittKarte
abschnitt={abschnitt}
index={index}
anzahl={aufbau.abschnitte.length}
lesend={lesend}
zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser}
aendern={(teil) => abschnittAendern(abschnitt.kennung, teil)}
loeschen={() =>
abschnitteSetzen(
aufbau.abschnitte.filter((eintrag) => eintrag.kennung !== abschnitt.kennung),
)
}
aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))}
schrittAendern={(kennung, teil) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.map((schritt) =>
schritt.kennung === kennung ? { ...schritt, ...teil } : schritt,
),
)
}
schrittHinzufuegen={() => {
const schritt = neuerSchritt();
setzeZuletztAngelegt(schritt.kennung);
schritteSetzen(abschnitt.kennung, (schritte) => [...schritte, schritt]);
}}
schrittLoeschen={(kennung) =>
schritteSetzen(abschnitt.kennung, (schritte) =>
schritte.filter((schritt) => schritt.kennung !== kennung),
)
}
schritteUmsortieren={(von, nach) =>
schritteSetzen(abschnitt.kennung, (schritte) => umsortieren(schritte, von, nach))
}
/>
)}
</SortierZeile>
))}
</ul>
</SortierBereich>
)}
{lesend ? null : (
<TextKnopf
beschriftung={
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
}
aus={abschnitteVoll}
aufKlick={() => {
const abschnitt = neuerAbschnitt();
setzeZuletztAngelegt(abschnitt.kennung);
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
}}
/>
)}
</section>
{/* Ohne Set stuende ein doppelt vergebener Schluessel zweimal in der Hilfe. */}
<PlatzhalterHilfe
variablen={[
...new Set(
aufbau.variablen
.map((variable) => variable.schluessel.trim())
.filter((schluessel) => schluessel !== ""),
),
]}
/>
<Vorschau aufbau={aufbau} beispielDatum={beispielDatum} />
{lesend ? null : (
<>
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<h2 className="text-lg font-semibold">Veröffentlichen</h2>
<p className="max-w-prose text-sm text-nebentext">
Aus dem Entwurf wird Version {version.version}. Sie ist danach unveränderlich, und Läufe können
daran starten. Gespeichert wird veröffentlicht — nicht, was hier gerade auf dem Bildschirm
steht.
</p>
<form action={veroeffentlichen} className="flex flex-col gap-3">
<input type="hidden" name="versionId" value={version.id} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Änderungsnotiz (optional)</span>
<textarea
name="aenderungsnotiz"
rows={2}
defaultValue={version.aenderungsnotiz ?? ""}
maxLength={GRENZEN.text}
className="min-h-11 w-full rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-text outline-none focus-visible:border-akzent"
/>
</label>
{nichtGespeichert ? (
<p className="flex items-start gap-2 text-sm text-faellig">
<TriangleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
Es gibt nicht gespeicherte Änderungen. Sie wären in der veröffentlichten Version nicht
enthalten — erst speichern.
</p>
) : null}
{freigabeErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{freigabeErgebnis.fehler}
</p>
) : null}
{freigabeErgebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{freigabeErgebnis.erfolg}
</p>
) : null}
<button
type="submit"
disabled={veroeffentlichtGerade}
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-5 text-sm font-semibold text-akzent hover:bg-akzent-flaeche disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Send className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
{veroeffentlichtGerade ? "Wird veröffentlicht …" : "Veröffentlichen"}
</button>
</form>
</section>
<form
action={speichern}
className="sticky bottom-0 z-10 -mx-4 flex flex-col gap-2 border-t border-linie bg-flaeche px-4 py-3 md:-mx-8 md:px-8"
>
<input type="hidden" name="versionId" value={version.id} />
<input type="hidden" name="stand" value={speicherErgebnis.stand} />
<input type="hidden" name="struktur" value={aktuellesJson} />
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={speichertGerade}
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent hover:bg-akzent/80 disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Save className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{speichertGerade ? "Wird gespeichert …" : "Speichern"}
</button>
<span className={`text-sm ${nichtGespeichert ? "text-faellig" : "text-nebentext"}`}>
{nichtGespeichert ? "Nicht gespeicherte Änderungen" : "Alles gespeichert"}
</span>
</div>
{speicherErgebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{speicherErgebnis.fehler}
</p>
) : null}
{speicherErgebnis.erfolg && !nichtGespeichert ? (
<p role="status" className="text-sm text-erledigt">
{speicherErgebnis.erfolg}
</p>
) : null}
{/*
* Der Rettungsanker haengt an **jedem** fehlgeschlagenen Speichern,
* nicht nur am Wettlauf zweier Redakteure. Wer eine Stunde an einer
* Vorlage gesessen hat, soll seinen Aufbau herauskopieren koennen,
* egal woran es lag — an einer abgelaufenen Anmeldung, an einem
* leeren Pflichtfeld oder an der Datenbank.
*/}
{speicherErgebnis.fehler ? (
<div className="flex flex-col gap-2">
{konflikt ? (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen. Am besten den Entwurf in einem zweiten Tab öffnen, die fremde
Fassung ansehen und die eigenen Änderungen dort nachtragen.
</p>
) : (
<p className="max-w-prose text-sm text-nebentext">
Die Eingaben stehen noch hier und sind nicht verloren — ein Neuladen dieser Seite würde
sie allerdings verwerfen.
</p>
)}
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Aufbau als JSON herauskopieren
</summary>
<textarea
readOnly
value={aktuellesJson}
rows={6}
className="mono mt-1 w-full rounded-karte border border-linie bg-grund p-3 text-xs text-nebentext"
/>
</details>
</div>
) : null}
</form>
</>
)}
</div>
);
}