562 lines
23 KiB
TypeScript
562 lines
23 KiB
TypeScript
"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,
|
||
bausteineAnreichern,
|
||
neuerAbschnitt,
|
||
neuerBausteinAbschnitt,
|
||
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 type EinsetzbarerBaustein = {
|
||
readonly bausteinVersionId: string;
|
||
readonly name: string;
|
||
readonly titel: string;
|
||
readonly version: number;
|
||
readonly anzahlSchritte: number;
|
||
};
|
||
|
||
export function Editor({
|
||
version,
|
||
lesend,
|
||
darfBearbeiten,
|
||
beispielDatum,
|
||
bausteine = [],
|
||
abteilungen = [],
|
||
}: {
|
||
version: EditorVersion;
|
||
lesend: boolean;
|
||
darfBearbeiten: boolean;
|
||
/** Heutiges Datum, auf dem Server formatiert — siehe `beispielwerte`. */
|
||
beispielDatum: string;
|
||
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
|
||
bausteine?: readonly EinsetzbarerBaustein[];
|
||
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
|
||
abteilungen?: readonly string[];
|
||
}) {
|
||
const router = useRouter();
|
||
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
|
||
bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine),
|
||
);
|
||
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(bausteineAnreichern(alsEditorAufbau(version.struktur), bausteine))),
|
||
[version.struktur, bausteine],
|
||
);
|
||
|
||
/**
|
||
* 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}
|
||
abteilungen={abteilungen}
|
||
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 : (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<TextKnopf
|
||
beschriftung={
|
||
abschnitteVoll ? `Höchstens ${GRENZEN.abschnitte} Abschnitte` : "Abschnitt hinzufügen"
|
||
}
|
||
aus={abschnitteVoll}
|
||
aufKlick={() => {
|
||
const abschnitt = neuerAbschnitt();
|
||
setzeZuletztAngelegt(abschnitt.kennung);
|
||
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||
}}
|
||
/>
|
||
{/*
|
||
Bausteine nur anbieten, wenn es welche gibt. Ein leeres
|
||
Auswahlfeld mit „keine Bausteine“ wäre eine Frage ohne Antwort
|
||
(#030).
|
||
*/}
|
||
{bausteine.length > 0 ? (
|
||
<label className="flex min-w-0 items-center gap-2 text-sm">
|
||
<span className="shrink-0 text-nebentext">Baustein einsetzen</span>
|
||
<select
|
||
aria-label="Baustein einsetzen"
|
||
value=""
|
||
disabled={abschnitteVoll}
|
||
onChange={(ereignis) => {
|
||
const gewaehlt = bausteine.find(
|
||
(eintrag) => eintrag.bausteinVersionId === ereignis.target.value,
|
||
);
|
||
if (!gewaehlt) return;
|
||
const abschnitt = neuerBausteinAbschnitt(gewaehlt);
|
||
setzeZuletztAngelegt(abschnitt.kennung);
|
||
abschnitteSetzen([...aufbau.abschnitte, abschnitt]);
|
||
}}
|
||
className="min-h-11 min-w-0 rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
|
||
>
|
||
<option value="">auswählen …</option>
|
||
{bausteine.map((eintrag) => (
|
||
<option key={eintrag.bausteinVersionId} value={eintrag.bausteinVersionId}>
|
||
{eintrag.name} (Fassung {eintrag.version}, {eintrag.anzahlSchritte}{" "}
|
||
{eintrag.anzahlSchritte === 1 ? "Schritt" : "Schritte"})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|