"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(() => 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 { 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(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) { 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 (
{lesend ? (
) : null} {unbekannt.length > 0 ? (
) : null} 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))} />

Abschnitte

{aufbau.abschnitte.length === 0 ? (

Noch kein Abschnitt. Ein Abschnitt fasst die Schritte zusammen, die zusammengehören.

) : ( abschnitt.kennung)} art="Abschnitt" lesend={lesend} aufSortiert={(von, nach) => abschnitteSetzen(umsortieren(aufbau.abschnitte, von, nach))} >
    {aufbau.abschnitte.map((abschnitt, index) => ( {(anfasser) => ( 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)) } /> )} ))}
)} {lesend ? null : ( { const abschnitt = neuerAbschnitt(); setzeZuletztAngelegt(abschnitt.kennung); abschnitteSetzen([...aufbau.abschnitte, abschnitt]); }} /> )}
{/* Ohne Set stuende ein doppelt vergebener Schluessel zweimal in der Hilfe. */} variable.schluessel.trim()) .filter((schluessel) => schluessel !== ""), ), ]} /> {lesend ? null : ( <>

Veröffentlichen

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.