508 lines
21 KiB
TypeScript
508 lines
21 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,
|
|
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>
|
|
);
|
|
}
|