feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
This commit was merged in pull request #24.
This commit is contained in:
@@ -22,6 +22,9 @@ import { darf } from "@/server/auth/rollen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { versionText } from "@/lib/version";
|
||||
|
||||
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
|
||||
const SCANNEN_VERFUEGBAR = false;
|
||||
|
||||
const SYMBOLE: Record<string, LucideIcon> = {
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
@@ -90,15 +93,46 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Einstieg des Technikers, an beiden Stellen ganz oben.
|
||||
*
|
||||
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
|
||||
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
|
||||
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
|
||||
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
|
||||
*/
|
||||
function ScannenKnopf({ className }: { className?: string }) {
|
||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||||
const gemeinsam = cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className);
|
||||
const inhalt = (
|
||||
<>
|
||||
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
Scannen
|
||||
</>
|
||||
);
|
||||
|
||||
if (!SCANNEN_VERFUEGBAR) {
|
||||
// Nicht als abgeblendeter Akzentknopf – das sieht aus wie ein Fehler. Die
|
||||
// gedaempfte Form ist dieselbe, die die Navigation fuer noch fehlende Seiten
|
||||
// benutzt.
|
||||
return (
|
||||
<span
|
||||
title="Kommt mit #014"
|
||||
aria-disabled="true"
|
||||
className={cn(
|
||||
gemeinsam,
|
||||
"flex cursor-not-allowed items-center justify-center border border-linie text-nebentext/60",
|
||||
)}
|
||||
>
|
||||
{inhalt}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||||
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
|
||||
<Link href="/scannen">
|
||||
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
Scannen
|
||||
</Link>
|
||||
<Button asChild className={gemeinsam}>
|
||||
<Link href="/scannen">{inhalt}</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
/**
|
||||
* Hilfetexte als Markdown – ohne rohes HTML.
|
||||
*
|
||||
* `react-markdown` laesst rohes HTML von sich aus fallen; `rehype-raw` wuerde es
|
||||
* wieder einschalten und ist deshalb bewusst nicht installiert. Ein Redakteur
|
||||
* darf Hilfetexte schreiben, aber kein Skript in die Abarbeitungsansicht
|
||||
* bringen, die jeder Techniker oeffnet.
|
||||
*
|
||||
* Links oeffnen in einem neuen Tab mit `rel="noreferrer"`, damit der Lauf beim
|
||||
* Klick nicht verlassen wird und die Zielseite nichts ueber die Herkunft erfaehrt.
|
||||
*/
|
||||
export function Markdown({ text, className }: { text: string; className?: string }) {
|
||||
return (
|
||||
<div className={`markdown text-sm text-nebentext ${className ?? ""}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
/*
|
||||
* `react-markdown` leert den `href`, wenn das Protokoll nicht erlaubt
|
||||
* ist. Ein `<a href="">` laedt beim Klick die aktuelle Seite in einem
|
||||
* neuen Tab — verwirrender als gar kein Verweis. Der Text bleibt
|
||||
* stehen, nur das Anklickbare faellt weg.
|
||||
*/
|
||||
a: ({ href, children }) =>
|
||||
href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-akzent underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-nebentext">{children}</span>
|
||||
),
|
||||
/*
|
||||
* Bilder werden **nicht** gerendert, sondern als Alternativtext
|
||||
* gezeigt. Ein Bild in einem Hilfetext laege auf einem fremden Host,
|
||||
* und `img-src` erlaubt nur `self`, `data:` und `blob:` — es wuerde
|
||||
* also ohnehin nie erscheinen, nur ein kaputtes Symbol hinterlassen.
|
||||
* Anhaenge sind fuer Phase 2 vorgesehen; bis dahin ist der Text
|
||||
* ehrlicher als ein leerer Rahmen.
|
||||
*/
|
||||
img: ({ alt }) => <span className="text-nebentext">[Bild{alt ? `: ${alt}` : ""}]</span>,
|
||||
code: ({ children }) => (
|
||||
<code className="mono rounded bg-flaeche-hoch px-1 py-0.5 text-[0.9em]">{children}</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => <ul className="list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-5">{children}</ol>,
|
||||
p: ({ children }) => <p className="[&:not(:first-child)]:mt-2">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Copy, Download, Trash2 } from "lucide-react";
|
||||
import {
|
||||
entwurfVerwerfenAktion,
|
||||
vorlageArchivierenAktion,
|
||||
vorlageDuplizierenAktion,
|
||||
vorlageExportierenAktion,
|
||||
type AktionsErgebnis,
|
||||
} from "@/server/actions/vorlagen";
|
||||
|
||||
// `w-fit`, damit ein Knopf in einem Block-Element nicht auf volle Breite geht –
|
||||
// drei verschieden breite Balken untereinander sehen aus wie ein Fehler.
|
||||
const KNOPF =
|
||||
"flex w-fit min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
|
||||
if (ergebnis.fehler) {
|
||||
return (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (ergebnis.erfolg) {
|
||||
return (
|
||||
<p role="status" className="mt-2 max-w-prose text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entwurf verwerfen.
|
||||
*
|
||||
* Mit Rueckfrage, weil ein Entwurf nicht protokolliert wird: was hier
|
||||
* verschwindet, ist wirklich weg. Die veroeffentlichte Version bleibt
|
||||
* unberuehrt — genau das ist der Sinn des Entwurfs.
|
||||
*/
|
||||
export function EntwurfVerwerfen({ versionId }: { versionId: string }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(entwurfVerwerfenAktion, {});
|
||||
const [sicher, setSicher] = useState(false);
|
||||
|
||||
if (!sicher) {
|
||||
return (
|
||||
<button type="button" onClick={() => setSicher(true)} className={KNOPF}>
|
||||
<Trash2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Entwurf verwerfen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="max-w-prose text-sm text-faellig">
|
||||
Der Entwurf wird gelöscht und ist nicht wiederherstellbar. Die veröffentlichte Version bleibt.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="versionId" value={versionId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="flex min-h-11 items-center rounded-pille border border-fehler px-4 text-sm text-fehler hover:bg-fehler/10 md:min-h-10"
|
||||
>
|
||||
{laeuft ? "Wird verworfen …" : "Ja, verwerfen"}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onClick={() => setSicher(false)} className={KNOPF}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<Meldung ergebnis={ergebnis} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Duplizieren: neue Vorlage, Inhalt der neuesten Version als Entwurf. */
|
||||
export function VorlageDuplizieren({ vorlageId, name }: { vorlageId: string; name: string }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageDuplizierenAktion, {});
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className={`${KNOPF} cursor-pointer list-none`}>
|
||||
<Copy className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Duplizieren
|
||||
</summary>
|
||||
<form action={aktion} className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<input type="hidden" name="vorlageId" value={vorlageId} />
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name der Kopie</span>
|
||||
<input name="name" required defaultValue={`${name} (Kopie)`} className={FELD} />
|
||||
</label>
|
||||
<button type="submit" disabled={laeuft} className={KNOPF}>
|
||||
{laeuft ? "Wird kopiert …" : "Kopie anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
<Meldung ergebnis={ergebnis} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Archivieren statt loeschen – sonst verloere das Archiv seine Bezuege. */
|
||||
export function VorlageArchivieren({ vorlageId, archiviert }: { vorlageId: string; archiviert: boolean }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageArchivierenAktion, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="vorlageId" value={vorlageId} />
|
||||
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
|
||||
<button type="submit" disabled={laeuft} className={KNOPF}>
|
||||
{archiviert ? "wieder freigeben" : "archivieren"}
|
||||
</button>
|
||||
</form>
|
||||
<Meldung ergebnis={ergebnis} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export einer Version als Datei.
|
||||
*
|
||||
* Die Server Action gibt den Text zurueck, und der Browser legt daraus die
|
||||
* Datei an. Ein Route Handler dafuer waere ein zweiter Weg zu denselben Daten,
|
||||
* mit eigener Rechtepruefung — und damit eine zweite Stelle, die falsch sein kann.
|
||||
*/
|
||||
export function VersionExportieren({ versionId }: { versionId: string }) {
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function exportieren() {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const ergebnis = await vorlageExportierenAktion(versionId);
|
||||
if (!("inhalt" in ergebnis)) {
|
||||
setFehler(ergebnis.fehler ?? "Der Export ist fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
const adresse = URL.createObjectURL(new Blob([ergebnis.inhalt], { type: "application/json" }));
|
||||
const verweis = document.createElement("a");
|
||||
verweis.href = adresse;
|
||||
verweis.download = ergebnis.dateiname;
|
||||
verweis.click();
|
||||
URL.revokeObjectURL(adresse);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => void exportieren()} disabled={laeuft} className={KNOPF}>
|
||||
<Download className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
{laeuft ? "Wird erzeugt …" : "Als JSON exportieren"}
|
||||
</button>
|
||||
{fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { vorlageAnlegenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
/**
|
||||
* Nur die Kopfdaten. Abschnitte, Schritte und Variablen entstehen danach im
|
||||
* Editor — ein Formular, das alles auf einmal wollte, waere eine Wand aus
|
||||
* Feldern, bevor ueberhaupt etwas existiert.
|
||||
*/
|
||||
export function VorlageAnlegen({ kunden }: { kunden: readonly { id: string; name: string }[] }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageAnlegenAktion, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input name="name" required className={FELD} placeholder="PC-Neuinstallation" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Beschreibung (optional)</span>
|
||||
<textarea name="beschreibung" rows={2} className={`${FELD} py-2`} />
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kategorie (optional)</span>
|
||||
<input name="kategorie" className={FELD} placeholder="Client" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
|
||||
<input name="schlagworte" className={FELD} placeholder="windows, onboarding" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Die Kundenwahl erscheint nur im Modus Dienstleistung – im Modus Intern
|
||||
* gibt es keine, und der Server verwirft eine mitgeschickte Kennung
|
||||
* ohnehin (#008).
|
||||
*/}
|
||||
{kunden.length > 0 ? (
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Nur für einen Kunden (optional)</span>
|
||||
<select name="kundeId" defaultValue="" className={FELD}>
|
||||
<option value="">für alle Kunden</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird angelegt …" : "Vorlage anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { entwurfOeffnenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
|
||||
|
||||
/**
|
||||
* „Bearbeiten“ – der wichtigste Knopf der Vorlagenseite.
|
||||
*
|
||||
* Er legt keinen Text um, sondern erzeugt eine **tiefe Kopie** der letzten
|
||||
* veroeffentlichten Version als neuen Entwurf (#009) und fuehrt dann in den
|
||||
* Editor. Gibt es schon einen Entwurf, bekommt man genau diesen — auch wenn zwei
|
||||
* Redakteure gleichzeitig klicken.
|
||||
*
|
||||
* Als Client-Komponente, weil die Server Action die Kennung des Entwurfs
|
||||
* zurueckgibt und erst danach klar ist, wohin es geht.
|
||||
*/
|
||||
export function VorlageBearbeitenKnopf({
|
||||
vorlageId,
|
||||
hatEntwurf,
|
||||
}: {
|
||||
vorlageId: string;
|
||||
hatEntwurf: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis & { versionId?: string }, FormData>(
|
||||
entwurfOeffnenAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ergebnis.versionId) {
|
||||
router.push(`/vorlagen/version/${ergebnis.versionId}`);
|
||||
}
|
||||
}, [ergebnis.versionId, router]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="vorlageId" value={vorlageId} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft || ergebnis.versionId !== undefined}
|
||||
className="h-11 gap-2 rounded-pille px-5 text-sm font-semibold md:h-10"
|
||||
>
|
||||
<Pencil className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
{laeuft || ergebnis.versionId
|
||||
? "Editor wird geöffnet …"
|
||||
: hatEntwurf
|
||||
? "Entwurf weiter bearbeiten"
|
||||
: "Bearbeiten"}
|
||||
</Button>
|
||||
</form>
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { vorlageImportierenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
|
||||
|
||||
/**
|
||||
* Einspielen eines Austauschdokuments.
|
||||
*
|
||||
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt,
|
||||
* statt sie als Datei-Upload durch die Server Action zu schleusen. Das Format
|
||||
* ist Text, die Datei ist klein, und so bleibt die Action dieselbe wie fuer
|
||||
* eingefuegten Inhalt — ein Weg statt zwei.
|
||||
*
|
||||
* Eingespielt wird immer eine **neue** Vorlage als Entwurf, nie in eine
|
||||
* bestehende hinein (docs/vorlagen-format.md).
|
||||
*/
|
||||
export function VorlageImportieren() {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageImportierenAktion, {});
|
||||
const [inhalt, setInhalt] = useState("");
|
||||
const [leseFehler, setLeseFehler] = useState<string | null>(null);
|
||||
|
||||
async function dateiLesen(datei: File | undefined) {
|
||||
if (!datei) return;
|
||||
setLeseFehler(null);
|
||||
try {
|
||||
setInhalt(await datei.text());
|
||||
} catch {
|
||||
setLeseFehler("Die Datei ließ sich nicht lesen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Vorlagendatei (.json)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
|
||||
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">… oder Inhalt einfügen</span>
|
||||
<textarea
|
||||
name="dokument"
|
||||
rows={6}
|
||||
required
|
||||
value={inhalt}
|
||||
onChange={(ereignis) => setInhalt(ereignis.target.value)}
|
||||
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
|
||||
placeholder={'{ "format": 1, "name": "…", "struktur": { … } }'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{leseFehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{leseFehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird eingespielt …" : "Einspielen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { vorlageBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
/**
|
||||
* Kopfdaten einer Vorlage: Name, Beschreibung, Kategorie, Schlagworte, Kunde.
|
||||
*
|
||||
* Sie haengen an der Vorlage, nicht an einer Version — deshalb lassen sie sich
|
||||
* jederzeit aendern, auch wenn die neueste Version veroeffentlicht ist. Ein
|
||||
* Tippfehler im Namen soll keine neue Version kosten, und die Schritte, auf die
|
||||
* ein Lauf verweist, bleiben davon unberuehrt.
|
||||
*/
|
||||
export function VorlageKopfdaten({
|
||||
vorlage,
|
||||
kunden,
|
||||
}: {
|
||||
vorlage: {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly beschreibung: string | null;
|
||||
readonly kategorie: string | null;
|
||||
readonly schlagworte: readonly string[];
|
||||
readonly kundeId: string | null;
|
||||
};
|
||||
kunden: readonly { id: string; name: string }[];
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageBearbeitenAktion, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="vorlageId" value={vorlage.id} />
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input name="name" required defaultValue={vorlage.name} className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Beschreibung</span>
|
||||
<textarea
|
||||
name="beschreibung"
|
||||
rows={2}
|
||||
defaultValue={vorlage.beschreibung ?? ""}
|
||||
className={`${FELD} py-2`}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kategorie</span>
|
||||
<input name="kategorie" defaultValue={vorlage.kategorie ?? ""} className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Schlagworte, mit Komma getrennt</span>
|
||||
<input name="schlagworte" defaultValue={vorlage.schlagworte.join(", ")} className={FELD} />
|
||||
</label>
|
||||
</div>
|
||||
{kunden.length > 0 ? (
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Nur für einen Kunden</span>
|
||||
<select name="kundeId" defaultValue={vorlage.kundeId ?? ""} className={FELD}>
|
||||
<option value="">für alle Kunden</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird gespeichert …" : "Kopfdaten speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
import { abschnittNummer, type EditorAbschnitt, type EditorSchritt } from "@/lib/editor-aufbau";
|
||||
import { GRENZEN } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Ein Abschnitt mit seinen Schritten (#010).
|
||||
*
|
||||
* Die Nummer links steht fuer die Position und wird nie gespeichert — wer den
|
||||
* Abschnitt verschiebt, bekommt sofort die neue Nummer zu sehen.
|
||||
*
|
||||
* Aufgeklappt als Standard, anders als Schritte und Variablen: man arbeitet in
|
||||
* aller Regel in einem Abschnitt und will seine Schritte sehen. Zugeklappt steht
|
||||
* die Zahl der Schritte daneben, damit beim Umsortieren klar ist, was da
|
||||
* gerade wandert.
|
||||
*
|
||||
* Schritte werden innerhalb ihres Abschnitts sortiert; jeder Abschnitt hat dazu
|
||||
* seinen eigenen Sortierbereich. Ein Schritt laesst sich damit nicht in einen
|
||||
* anderen Abschnitt ziehen — das braucht die Vorlagenpflege nicht, und ein
|
||||
* Bereich ueber alle Abschnitte hinweg haette am Handy Ziehwege von mehreren
|
||||
* Bildschirmhoehen.
|
||||
*/
|
||||
export function AbschnittKarte({
|
||||
abschnitt,
|
||||
index,
|
||||
anzahl,
|
||||
lesend,
|
||||
zuletztAngelegt,
|
||||
anfasser,
|
||||
aendern,
|
||||
loeschen,
|
||||
aufSortiert,
|
||||
schrittAendern,
|
||||
schrittHinzufuegen,
|
||||
schrittLoeschen,
|
||||
schritteUmsortieren,
|
||||
}: {
|
||||
abschnitt: EditorAbschnitt;
|
||||
index: number;
|
||||
anzahl: number;
|
||||
lesend: boolean;
|
||||
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
||||
zuletztAngelegt: string | null;
|
||||
anfasser: React.ReactNode;
|
||||
aendern: (teil: Partial<EditorAbschnitt>) => void;
|
||||
loeschen: () => void;
|
||||
aufSortiert: (von: number, nach: number) => void;
|
||||
schrittAendern: (kennung: string, teil: Partial<EditorSchritt>) => void;
|
||||
schrittHinzufuegen: () => void;
|
||||
schrittLoeschen: (kennung: string) => void;
|
||||
schritteUmsortieren: (von: number, nach: number) => void;
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(true);
|
||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
const anzahlSchritte = abschnitt.schritte.length;
|
||||
|
||||
return (
|
||||
<KlappKarte
|
||||
offen={offen}
|
||||
umschalten={() => setzeOffen((vorher) => !vorher)}
|
||||
flaeche="flaeche"
|
||||
ueberschrift
|
||||
anfasser={anfasser}
|
||||
kopf={
|
||||
<>
|
||||
<span className="zahl shrink-0 text-lg text-akzent">{abschnittNummer(index)}</span>
|
||||
<span className="shrink-0 text-nebentext">·</span>
|
||||
<span className={`truncate font-semibold ${abschnitt.titel.trim() === "" ? "text-nebentext" : ""}`}>
|
||||
{abschnitt.titel.trim() === "" ? "ohne Titel" : abschnitt.titel}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-normal text-nebentext">
|
||||
{anzahlSchritte} {anzahlSchritte === 1 ? "Schritt" : "Schritte"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
knoepfe={
|
||||
lesend ? null : (
|
||||
<>
|
||||
<VerschiebeKnoepfe art="Abschnitt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
|
||||
<LoeschKnopf
|
||||
beschriftung="Abschnitt löschen"
|
||||
{...(anzahlSchritte > 0
|
||||
? {
|
||||
frage: `Abschnitt mit ${anzahlSchritte} ${
|
||||
anzahlSchritte === 1 ? "Schritt" : "Schritten"
|
||||
} löschen?`,
|
||||
}
|
||||
: {})}
|
||||
aufLoeschen={loeschen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{lesend ? null : (
|
||||
<Textfeld
|
||||
beschriftung="Titel des Abschnitts"
|
||||
wert={abschnitt.titel}
|
||||
setzen={(titel) => aendern({ titel })}
|
||||
lesend={lesend}
|
||||
vorschlag="Vorbereitung"
|
||||
/>
|
||||
)}
|
||||
|
||||
{anzahlSchritte === 0 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||
) : (
|
||||
<SortierBereich
|
||||
kennungen={abschnitt.schritte.map((schritt) => schritt.kennung)}
|
||||
art="Schritt"
|
||||
lesend={lesend}
|
||||
aufSortiert={schritteUmsortieren}
|
||||
>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{abschnitt.schritte.map((schritt, stelle) => (
|
||||
<SortierZeile key={schritt.kennung} kennung={schritt.kennung} lesend={lesend}>
|
||||
{(schrittAnfasser) => (
|
||||
<SchrittKarte
|
||||
schritt={schritt}
|
||||
index={stelle}
|
||||
anzahl={anzahlSchritte}
|
||||
lesend={lesend}
|
||||
neu={schritt.kennung === zuletztAngelegt}
|
||||
anfasser={schrittAnfasser}
|
||||
aendern={(teil) => schrittAendern(schritt.kennung, teil)}
|
||||
loeschen={() => schrittLoeschen(schritt.kennung)}
|
||||
aufSortiert={schritteUmsortieren}
|
||||
/>
|
||||
)}
|
||||
</SortierZeile>
|
||||
))}
|
||||
</ul>
|
||||
</SortierBereich>
|
||||
)}
|
||||
|
||||
{lesend ? null : (
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.schritteJeAbschnitt} Schritte` : "Schritt hinzufügen"}
|
||||
aus={voll}
|
||||
aufKlick={schrittHinzufuegen}
|
||||
/>
|
||||
)}
|
||||
</KlappKarte>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown, Plus, Trash2, type LucideIcon } from "lucide-react";
|
||||
import { GRENZEN } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Die kleinen Bauteile des Vorlagen-Editors (#010).
|
||||
*
|
||||
* Jedes traegt einen `lesend`-Schalter statt einer zweiten, nur lesenden
|
||||
* Fassung: eine veroeffentlichte Version wird mit denselben Bauteilen gezeigt
|
||||
* wie ein Entwurf. Zwei Codepfade waeren zwei Gelegenheiten, dass die Ansicht
|
||||
* etwas anderes zeigt als der Editor.
|
||||
*
|
||||
* Eingabefelder werden im Lesemodus `readOnly` und nicht `disabled`: der Inhalt
|
||||
* bleibt markierbar, und ein Befehlstext laesst sich herauskopieren. Auswahl und
|
||||
* Kontrollkasten kennen kein `readOnly`, die werden `disabled`.
|
||||
*/
|
||||
|
||||
export const FELD =
|
||||
"min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent read-only:text-nebentext";
|
||||
|
||||
const FLAECHE_FELD = `${FELD} py-2`;
|
||||
|
||||
/** Knopf mit Symbol, 44 px am Handy. Beschriftung nur fuer Hilfsmittel. */
|
||||
export function SymbolKnopf({
|
||||
symbol: Symbol,
|
||||
beschriftung,
|
||||
aufKlick,
|
||||
aus,
|
||||
ton,
|
||||
}: {
|
||||
symbol: LucideIcon;
|
||||
beschriftung: string;
|
||||
aufKlick: () => void;
|
||||
aus?: boolean;
|
||||
ton?: "fehler";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={aufKlick}
|
||||
disabled={aus}
|
||||
title={beschriftung}
|
||||
aria-label={beschriftung}
|
||||
className={`flex size-11 shrink-0 items-center justify-center rounded-pille border border-linie md:size-10 ${
|
||||
ton === "fehler"
|
||||
? "text-nebentext hover:bg-flaeche-hoch hover:text-fehler"
|
||||
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||||
} disabled:pointer-events-none disabled:opacity-40`}
|
||||
>
|
||||
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flacher Knopf mit Text, fuer „hinzufügen“. */
|
||||
export function TextKnopf({
|
||||
beschriftung,
|
||||
aufKlick,
|
||||
aus,
|
||||
symbol: Symbol = Plus,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
aufKlick: () => void;
|
||||
aus?: boolean;
|
||||
symbol?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={aufKlick}
|
||||
disabled={aus}
|
||||
className="flex min-h-11 items-center gap-2 self-start rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||
>
|
||||
<Symbol className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
|
||||
{beschriftung}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschen mit Rueckfrage an der Stelle, an der geklickt wurde.
|
||||
*
|
||||
* Die Rueckfrage nur dort, wo mehr als eine Zeile verloren geht — einen leeren
|
||||
* Abschnitt oder eine Option legt man in zwei Sekunden neu an, und eine
|
||||
* Rueckfrage, die man immer wegklickt, wird nicht gelesen.
|
||||
*/
|
||||
export function LoeschKnopf({
|
||||
beschriftung,
|
||||
frage,
|
||||
aufLoeschen,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
frage?: string;
|
||||
aufLoeschen: () => void;
|
||||
}) {
|
||||
const [fragt, setzeFragt] = useState(false);
|
||||
|
||||
if (frage && fragt) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-xs text-faellig">{frage}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={aufLoeschen}
|
||||
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-fehler hover:bg-flaeche-hoch md:min-h-10"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setzeFragt(false)}
|
||||
className="min-h-11 rounded-pille px-3 text-sm text-nebentext hover:text-text md:min-h-10"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SymbolKnopf
|
||||
symbol={Trash2}
|
||||
beschriftung={beschriftung}
|
||||
ton="fehler"
|
||||
aufKlick={() => (frage ? setzeFragt(true) : aufLoeschen())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textfeld({
|
||||
beschriftung,
|
||||
wert,
|
||||
setzen,
|
||||
lesend,
|
||||
hinweis,
|
||||
fehler,
|
||||
mono,
|
||||
vorschlag,
|
||||
maxLaenge = GRENZEN.text,
|
||||
versteckteBeschriftung,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
wert: string;
|
||||
setzen: (wert: string) => void;
|
||||
lesend: boolean;
|
||||
hinweis?: string;
|
||||
fehler?: string;
|
||||
mono?: boolean;
|
||||
vorschlag?: string;
|
||||
maxLaenge?: number;
|
||||
/** Fuer Felder, deren Bedeutung aus dem Zusammenhang klar ist — etwa der Titel in einer Zeile. */
|
||||
versteckteBeschriftung?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
|
||||
<span className={versteckteBeschriftung ? "sr-only" : "text-nebentext"}>{beschriftung}</span>
|
||||
<input
|
||||
value={wert}
|
||||
onChange={(ereignis) => setzen(ereignis.target.value)}
|
||||
readOnly={lesend}
|
||||
maxLength={maxLaenge}
|
||||
// Kein Vorschlagstext im Lesemodus: ein grauer Beispielwert in einem
|
||||
// Feld, das niemand fuellen kann, sieht aus wie ein Inhalt.
|
||||
placeholder={lesend ? undefined : vorschlag}
|
||||
aria-invalid={fehler ? true : undefined}
|
||||
className={`${FELD} ${mono ? "mono" : ""} ${fehler ? "border-fehler" : ""}`}
|
||||
/>
|
||||
{fehler ? (
|
||||
<span role="alert" className="text-xs text-fehler">
|
||||
{fehler}
|
||||
</span>
|
||||
) : hinweis ? (
|
||||
<span className="text-xs text-nebentext">{hinweis}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Flaechenfeld({
|
||||
beschriftung,
|
||||
wert,
|
||||
setzen,
|
||||
lesend,
|
||||
hinweis,
|
||||
zeilen = 3,
|
||||
mono,
|
||||
vorschlag,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
wert: string;
|
||||
setzen: (wert: string) => void;
|
||||
lesend: boolean;
|
||||
hinweis?: string;
|
||||
zeilen?: number;
|
||||
mono?: boolean;
|
||||
vorschlag?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">{beschriftung}</span>
|
||||
<textarea
|
||||
value={wert}
|
||||
onChange={(ereignis) => setzen(ereignis.target.value)}
|
||||
readOnly={lesend}
|
||||
rows={zeilen}
|
||||
maxLength={GRENZEN.text}
|
||||
placeholder={lesend ? undefined : vorschlag}
|
||||
className={`${FLAECHE_FELD} ${mono ? "mono" : ""}`}
|
||||
/>
|
||||
{hinweis ? <span className="text-xs text-nebentext">{hinweis}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Auswahlfeld<T extends string>({
|
||||
beschriftung,
|
||||
wert,
|
||||
werte,
|
||||
setzen,
|
||||
lesend,
|
||||
beschriftungen,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
wert: T;
|
||||
werte: readonly T[];
|
||||
setzen: (wert: T) => void;
|
||||
lesend: boolean;
|
||||
beschriftungen?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">{beschriftung}</span>
|
||||
<select
|
||||
value={wert}
|
||||
onChange={(ereignis) => setzen(ereignis.target.value as T)}
|
||||
disabled={lesend}
|
||||
className={`${FELD} disabled:text-nebentext`}
|
||||
>
|
||||
{werte.map((eintrag) => (
|
||||
<option key={eintrag} value={eintrag}>
|
||||
{beschriftungen?.[eintrag] ?? eintrag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kontrollkasten({
|
||||
beschriftung,
|
||||
wert,
|
||||
setzen,
|
||||
lesend,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
wert: boolean;
|
||||
setzen: (wert: boolean) => void;
|
||||
lesend: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-11 shrink-0 cursor-pointer items-center gap-2 text-sm md:min-h-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wert}
|
||||
onChange={(ereignis) => setzen(ereignis.target.checked)}
|
||||
disabled={lesend}
|
||||
className="size-5 accent-akzent"
|
||||
/>
|
||||
<span className="text-nebentext">{beschriftung}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zahlfeld fuer `min` und `max`: leeres Feld heisst „keine Grenze“, nicht „null“. */
|
||||
export function Zahlfeld({
|
||||
beschriftung,
|
||||
wert,
|
||||
setzen,
|
||||
lesend,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
wert: number | undefined;
|
||||
setzen: (wert: number | undefined) => void;
|
||||
lesend: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">{beschriftung}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={wert ?? ""}
|
||||
onChange={(ereignis) => {
|
||||
const roh = ereignis.target.value;
|
||||
const zahl = Number(roh);
|
||||
setzen(roh === "" || !Number.isFinite(zahl) ? undefined : zahl);
|
||||
}}
|
||||
readOnly={lesend}
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Optionen einer Auswahl — bei einem Schritt wie bei einer Variablen.
|
||||
*
|
||||
* Die Obergrenze steht am Knopf, bevor jemand dagegen laeuft; das Schema
|
||||
* beanstandet sie sonst erst beim Speichern.
|
||||
*/
|
||||
export function OptionenFelder({
|
||||
optionen,
|
||||
setzen,
|
||||
lesend,
|
||||
}: {
|
||||
optionen: readonly string[];
|
||||
setzen: (optionen: readonly string[]) => void;
|
||||
lesend: boolean;
|
||||
}) {
|
||||
const voll = optionen.length >= GRENZEN.optionen;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-nebentext">Optionen</span>
|
||||
{optionen.length === 0 ? (
|
||||
<p className="text-xs text-faellig">
|
||||
Ohne Option bleibt die Auswahl leer. Bitte mindestens eine angeben.
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{optionen.map((option, index) => (
|
||||
// Der Index als Schluessel ist hier richtig: die Optionen sind reine
|
||||
// Zeichenketten ohne eigene Kennung, und geloescht wird immer die Zeile,
|
||||
// auf die geklickt wurde.
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
value={option}
|
||||
onChange={(ereignis) =>
|
||||
setzen(optionen.map((alt, stelle) => (stelle === index ? ereignis.target.value : alt)))
|
||||
}
|
||||
readOnly={lesend}
|
||||
maxLength={GRENZEN.text}
|
||||
aria-label={`Option ${index + 1}`}
|
||||
className={FELD}
|
||||
/>
|
||||
{lesend ? null : (
|
||||
<LoeschKnopf
|
||||
beschriftung={`Option ${index + 1} löschen`}
|
||||
aufLoeschen={() => setzen(optionen.filter((_, stelle) => stelle !== index))}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{lesend ? null : (
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.optionen} Optionen` : "Option hinzufügen"}
|
||||
aus={voll}
|
||||
aufKlick={() => setzen([...optionen, ""])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleine Pille fuer Status, Typ und Kennzeichen. */
|
||||
export function Kennzeichen({
|
||||
children,
|
||||
ton = "still",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
ton?: "still" | "akzent" | "faellig";
|
||||
}) {
|
||||
const toene = {
|
||||
still: "bg-flaeche-hoch text-nebentext",
|
||||
akzent: "bg-akzent-flaeche text-akzent",
|
||||
faellig: "border border-linie text-faellig",
|
||||
} as const;
|
||||
return <span className={`shrink-0 rounded-pille px-2.5 py-1 text-xs ${toene[ton]}`}>{children}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Karte, die sich zuklappen laesst (#010).
|
||||
*
|
||||
* Abschnitte, Schritte und Variablen sehen gleich aus: eine Kopfzeile, die immer
|
||||
* sichtbar bleibt — Anfasser, Zusammenfassung, Knoepfe —, und darunter die
|
||||
* Felder. Ohne das Zuklappen war eine einzige Beispielvorlage rund 17 000 px
|
||||
* hoch; mit den sieben Vorlagen aus #017 waere der Editor nicht mehr bedienbar.
|
||||
*
|
||||
* Der Inhalt wird **ausgeblendet, nicht ausgehaengt**: die Felder bleiben im
|
||||
* Baum und damit im React-Zustand. Wuerde man sie entfernen, verloere das
|
||||
* Zuklappen alles, was seit dem letzten Speichern getippt wurde.
|
||||
*
|
||||
* Kein `<details>`: dessen `<summary>` schaltet bei jedem Klick um — auch beim
|
||||
* Klick auf den Anfasser oder den Loeschknopf, die in der Kopfzeile stehen
|
||||
* muessen, weil man auch zugeklappt sortieren und loeschen koennen soll.
|
||||
*/
|
||||
export function KlappKarte({
|
||||
offen,
|
||||
umschalten,
|
||||
anfasser,
|
||||
knoepfe,
|
||||
kopf,
|
||||
ueberschrift,
|
||||
flaeche = "flaeche-hoch",
|
||||
children,
|
||||
}: {
|
||||
offen: boolean;
|
||||
umschalten: () => void;
|
||||
anfasser: React.ReactNode;
|
||||
knoepfe?: React.ReactNode;
|
||||
/** Was zugeklappt zu sehen ist. Traegt selbst `truncate`, wo es lang werden kann. */
|
||||
kopf: React.ReactNode;
|
||||
/** Abschnitte sind Ueberschriften; Schritte und Variablen nicht. */
|
||||
ueberschrift?: boolean;
|
||||
flaeche?: "flaeche" | "flaeche-hoch";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const inhaltKennung = useId();
|
||||
|
||||
const schalter = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={umschalten}
|
||||
aria-expanded={offen}
|
||||
aria-controls={inhaltKennung}
|
||||
className="flex min-h-11 w-full min-w-40 flex-1 items-center gap-2 text-left text-sm md:min-h-10"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{kopf}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-karte border border-linie ${
|
||||
flaeche === "flaeche" ? "bg-flaeche" : "bg-flaeche-hoch"
|
||||
}`}
|
||||
>
|
||||
{/*
|
||||
Eine Zeile, die umbricht: am Schreibtisch stehen Zusammenfassung und
|
||||
Knoepfe nebeneinander, bei 390 px rutschen die Knoepfe darunter, sobald
|
||||
der Text seine 10 rem nicht mehr bekommt.
|
||||
*/}
|
||||
<div className="flex flex-wrap items-center gap-2 p-3">
|
||||
{anfasser}
|
||||
{ueberschrift ? <h3 className="flex min-w-40 flex-1">{schalter}</h3> : schalter}
|
||||
{knoepfe ? <div className="ml-auto flex items-center gap-2">{knoepfe}</div> : null}
|
||||
</div>
|
||||
<div id={inhaltKennung} className={offen ? "flex flex-col gap-3 px-3 pb-3" : "hidden"}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Auswahlfeld,
|
||||
Flaechenfeld,
|
||||
Kennzeichen,
|
||||
KlappKarte,
|
||||
Kontrollkasten,
|
||||
LoeschKnopf,
|
||||
Textfeld,
|
||||
OptionenFelder,
|
||||
Zahlfeld,
|
||||
} from "@/components/vorlagen/editor-felder";
|
||||
import { VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import {
|
||||
SCHRITT_TYP_TEXT,
|
||||
STANDARD_SHELL,
|
||||
konfigFuerTyp,
|
||||
type EditorSchritt,
|
||||
type SchrittTyp,
|
||||
} from "@/lib/editor-aufbau";
|
||||
import { SCHRITT_TYPEN, SHELLS, type SchrittKonfig } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Ein Schritt im Editor (#010).
|
||||
*
|
||||
* Bewusst keine Tabelle und keine festen Breiten: bei 390 px stehen Titel,
|
||||
* Typ und die typabhaengigen Felder untereinander, ab `sm` nebeneinander.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Einen Schluessel in der `konfig` setzen oder entfernen.
|
||||
*
|
||||
* Ein leeres Feld loescht den Schluessel, statt `""` zu speichern: `min` ohne
|
||||
* Wert heisst „keine Grenze“, und eine leere Einheit ist keine Einheit.
|
||||
*/
|
||||
function konfigMit(konfig: SchrittKonfig, schluessel: string, wert: unknown): SchrittKonfig {
|
||||
const neu: Record<string, unknown> = { ...konfig };
|
||||
if (wert === undefined || wert === "") delete neu[schluessel];
|
||||
else neu[schluessel] = wert;
|
||||
return neu as SchrittKonfig;
|
||||
}
|
||||
|
||||
function KonfigFelder({
|
||||
typ,
|
||||
konfig,
|
||||
setzen,
|
||||
lesend,
|
||||
}: {
|
||||
typ: SchrittTyp;
|
||||
konfig: SchrittKonfig;
|
||||
setzen: (konfig: SchrittKonfig) => void;
|
||||
lesend: boolean;
|
||||
}) {
|
||||
switch (typ) {
|
||||
case "TEXT":
|
||||
return (
|
||||
<Kontrollkasten
|
||||
beschriftung="Mehrzeiliges Feld"
|
||||
wert={konfig.mehrzeilig === true}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "mehrzeilig", wert))}
|
||||
lesend={lesend}
|
||||
/>
|
||||
);
|
||||
|
||||
case "ZAHL":
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Zahlfeld
|
||||
beschriftung="Mindestens"
|
||||
wert={konfig.min}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "min", wert))}
|
||||
lesend={lesend}
|
||||
/>
|
||||
<Zahlfeld
|
||||
beschriftung="Höchstens"
|
||||
wert={konfig.max}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "max", wert))}
|
||||
lesend={lesend}
|
||||
/>
|
||||
<Textfeld
|
||||
beschriftung="Einheit"
|
||||
wert={konfig.einheit ?? ""}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "einheit", wert))}
|
||||
lesend={lesend}
|
||||
maxLaenge={40}
|
||||
vorschlag="GB"
|
||||
hinweis="Grenzen sind Hinweise, kein Verbot."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "AUSWAHL":
|
||||
return (
|
||||
<OptionenFelder
|
||||
optionen={konfig.optionen ?? []}
|
||||
setzen={(optionen) => setzen(konfigMit(konfig, "optionen", [...optionen]))}
|
||||
lesend={lesend}
|
||||
/>
|
||||
);
|
||||
|
||||
case "BEFEHL":
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="sm:max-w-48">
|
||||
<Auswahlfeld
|
||||
beschriftung="Shell"
|
||||
wert={konfig.shell ?? STANDARD_SHELL}
|
||||
werte={SHELLS}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "shell", wert))}
|
||||
lesend={lesend}
|
||||
/>
|
||||
</div>
|
||||
<Flaechenfeld
|
||||
beschriftung="Befehl"
|
||||
wert={konfig.befehl ?? ""}
|
||||
setzen={(wert) => setzen(konfigMit(konfig, "befehl", wert))}
|
||||
lesend={lesend}
|
||||
zeilen={4}
|
||||
mono
|
||||
vorschlag={'New-ADUser -Name "{{vorname}} {{nachname}}"'}
|
||||
hinweis="Platzhalter werden erst beim Abarbeiten ersetzt."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
// HAKEN und DATUM haben keine konfig. Dass dort nichts steht, sieht man;
|
||||
// ein Satz darueber waere Rauschen an jedem zweiten Schritt.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SchrittKarte({
|
||||
schritt,
|
||||
index,
|
||||
anzahl,
|
||||
lesend,
|
||||
neu,
|
||||
anfasser,
|
||||
aendern,
|
||||
loeschen,
|
||||
aufSortiert,
|
||||
}: {
|
||||
schritt: EditorSchritt;
|
||||
index: number;
|
||||
anzahl: number;
|
||||
lesend: boolean;
|
||||
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
|
||||
neu: boolean;
|
||||
anfasser: React.ReactNode;
|
||||
aendern: (teil: Partial<EditorSchritt>) => void;
|
||||
loeschen: () => void;
|
||||
aufSortiert: (von: number, nach: number) => void;
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(neu);
|
||||
|
||||
/**
|
||||
* Beim Typwechsel wird die `konfig` aufgeraeumt — sonst wanderte der Befehl
|
||||
* eines ehemaligen `BEFEHL`-Schritts unsichtbar mit. `BEFEHL` bekommt dabei
|
||||
* gleich eine Shell, damit gespeichert ist, was das Formular anzeigt.
|
||||
*/
|
||||
function typSetzen(typ: SchrittTyp) {
|
||||
const gesaeubert = konfigFuerTyp(schritt.konfig, typ);
|
||||
aendern({
|
||||
typ,
|
||||
konfig:
|
||||
typ === "BEFEHL" && gesaeubert.shell === undefined
|
||||
? konfigMit(gesaeubert, "shell", STANDARD_SHELL)
|
||||
: gesaeubert,
|
||||
});
|
||||
}
|
||||
|
||||
const titel = schritt.titel.trim();
|
||||
|
||||
return (
|
||||
<KlappKarte
|
||||
offen={offen}
|
||||
umschalten={() => setzeOffen((vorher) => !vorher)}
|
||||
anfasser={anfasser}
|
||||
kopf={
|
||||
<>
|
||||
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
|
||||
{schritt.pflicht ? (
|
||||
<span className="shrink-0 text-fehler" title="Pflichtschritt">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{/* Ohne Titel bliebe die Zeile leer — und der Schritt unauffindbar. */}
|
||||
<span className={`truncate ${titel === "" ? "text-nebentext" : ""}`}>
|
||||
{titel === "" ? "ohne Titel" : titel}
|
||||
</span>
|
||||
<Kennzeichen>{SCHRITT_TYP_TEXT[schritt.typ]}</Kennzeichen>
|
||||
</>
|
||||
}
|
||||
knoepfe={
|
||||
lesend ? null : (
|
||||
<>
|
||||
<VerschiebeKnoepfe art="Schritt" index={index} anzahl={anzahl} aufSortiert={aufSortiert} />
|
||||
<LoeschKnopf beschriftung="Schritt löschen" aufLoeschen={loeschen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Textfeld
|
||||
beschriftung="Titel"
|
||||
wert={schritt.titel}
|
||||
setzen={(neuerTitel) => aendern({ titel: neuerTitel })}
|
||||
lesend={lesend}
|
||||
vorschlag="Was ist zu tun?"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="sm:max-w-48">
|
||||
<Auswahlfeld
|
||||
beschriftung="Typ"
|
||||
wert={schritt.typ}
|
||||
werte={SCHRITT_TYPEN}
|
||||
setzen={typSetzen}
|
||||
lesend={lesend}
|
||||
beschriftungen={SCHRITT_TYP_TEXT}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:mt-6">
|
||||
<Kontrollkasten
|
||||
beschriftung="Pflichtschritt"
|
||||
wert={schritt.pflicht}
|
||||
setzen={(pflicht) => aendern({ pflicht })}
|
||||
lesend={lesend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KonfigFelder
|
||||
typ={schritt.typ}
|
||||
konfig={schritt.konfig}
|
||||
setzen={(konfig) => aendern({ konfig })}
|
||||
lesend={lesend}
|
||||
/>
|
||||
|
||||
<Flaechenfeld
|
||||
beschriftung="Hilfetext"
|
||||
wert={schritt.hilfetext}
|
||||
setzen={(hilfetext) => aendern({ hilfetext })}
|
||||
lesend={lesend}
|
||||
hinweis="Markdown: **fett**, Listen, Links, `Code`. Kein HTML."
|
||||
/>
|
||||
<Textfeld
|
||||
beschriftung="Doku-Link"
|
||||
wert={schritt.dokuUrl}
|
||||
setzen={(dokuUrl) => aendern({ dokuUrl })}
|
||||
lesend={lesend}
|
||||
vorschlag="https://intern.example/anleitung"
|
||||
hinweis="Nur http- oder https-Adressen."
|
||||
/>
|
||||
</KlappKarte>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
|
||||
import { SymbolKnopf } from "@/components/vorlagen/editor-felder";
|
||||
|
||||
/**
|
||||
* Reihenfolge aendern — mit dem Finger, der Maus, der Tastatur oder zwei Knoepfen (#010).
|
||||
*
|
||||
* Drei Sensoren, weil ein Anfasser allein nicht reicht:
|
||||
*
|
||||
* - `TouchSensor` mit Verzoegerung: ohne sie waere jede Wischbewegung, die auf
|
||||
* dem Anfasser beginnt, ein Verschieben statt eines Bildlaufs. 200 ms Halten
|
||||
* sind genug, um das eine vom anderen zu unterscheiden.
|
||||
* - `MouseSensor` mit 4 px Schwelle, damit ein Klick kein Verschieben auslöst.
|
||||
* Bewusst nicht der `PointerSensor`: der bediente Maus und Finger zugleich und
|
||||
* liesse sich dann nicht mehr fuer den Finger allein einstellen.
|
||||
* - `KeyboardSensor` fuer Leertaste und Pfeiltasten.
|
||||
*
|
||||
* Dazu die Knoepfe „nach oben“ und „nach unten“ an jeder Zeile: ein Touch-Drag
|
||||
* auf einem 390-px-Gerät gelingt nicht jedem zuverlässig, und die Reihenfolge
|
||||
* ist fachlich wichtig genug, um einen zweiten, langsamen Weg zu haben.
|
||||
*/
|
||||
|
||||
/** Kein Import aus `@dnd-kit/utilities` — das Paket ist nur eine Abhaengigkeit der Abhaengigkeit. */
|
||||
function schiebeStil(
|
||||
transform: { x: number; y: number } | null,
|
||||
transition: string | undefined,
|
||||
): React.CSSProperties | undefined {
|
||||
if (!transform) return transition ? { transition } : undefined;
|
||||
return { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transition };
|
||||
}
|
||||
|
||||
export function SortierBereich({
|
||||
kennungen,
|
||||
art,
|
||||
lesend,
|
||||
aufSortiert,
|
||||
children,
|
||||
}: {
|
||||
kennungen: readonly string[];
|
||||
/** „Abschnitt“, „Schritt“, „Variable“ — fuer die Ansagen der Hilfsmittel. */
|
||||
art: string;
|
||||
lesend: boolean;
|
||||
aufSortiert: (von: number, nach: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
/**
|
||||
* Eine feste Kennung fuer den Sortierbereich.
|
||||
*
|
||||
* Ohne sie vergibt `@dnd-kit` die `aria-describedby`-Kennung aus einem
|
||||
* modulweiten Zaehler. Der steht auf dem Server anders als im Browser — und
|
||||
* genau das meldet React beim Hydrieren als Unterschied. `useId` zaehlt in
|
||||
* beiden Durchlaeufen gleich.
|
||||
*/
|
||||
const bereichKennung = useId();
|
||||
|
||||
const sensoren = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Im Lesemodus gar kein DndContext: was sich nicht aendern darf, soll auch
|
||||
// keine Ansage machen, dass es sich verschieben liesse.
|
||||
if (lesend) return children;
|
||||
|
||||
function aufEnde(ereignis: DragEndEvent) {
|
||||
const { active, over } = ereignis;
|
||||
if (!over || active.id === over.id) return;
|
||||
const von = kennungen.indexOf(String(active.id));
|
||||
const nach = kennungen.indexOf(String(over.id));
|
||||
if (von < 0 || nach < 0) return;
|
||||
aufSortiert(von, nach);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={bereichKennung}
|
||||
sensors={sensoren}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={aufEnde}
|
||||
accessibility={{
|
||||
screenReaderInstructions: {
|
||||
draggable: `${art} mit der Leertaste aufnehmen, mit den Pfeiltasten verschieben, mit der Leertaste ablegen, mit Escape abbrechen.`,
|
||||
},
|
||||
announcements: {
|
||||
onDragStart: () => `${art} aufgenommen.`,
|
||||
onDragOver: () => undefined,
|
||||
onDragEnd: ({ over }) => (over ? `${art} verschoben.` : `${art} unverändert abgelegt.`),
|
||||
onDragCancel: () => "Verschieben abgebrochen.",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SortableContext items={[...kennungen]} strategy={verticalListSortingStrategy}>
|
||||
{children}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile, die sich verschieben laesst.
|
||||
*
|
||||
* Der Anfasser kommt als Bauteil zurueck, damit die Zeile selbst entscheidet, wo
|
||||
* er sitzt. Im Lesemodus eine andere Komponente statt eines abgeschalteten
|
||||
* `useSortable`: so haengt kein Sortier-Zustand an etwas, das unveraenderlich ist.
|
||||
*/
|
||||
export function SortierZeile({
|
||||
kennung,
|
||||
lesend,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
kennung: string;
|
||||
lesend: boolean;
|
||||
className?: string;
|
||||
children: (anfasser: React.ReactNode) => React.ReactNode;
|
||||
}) {
|
||||
if (lesend) return <li className={className}>{children(null)}</li>;
|
||||
return (
|
||||
<BeweglicheZeile kennung={kennung} className={className}>
|
||||
{children}
|
||||
</BeweglicheZeile>
|
||||
);
|
||||
}
|
||||
|
||||
function BeweglicheZeile({
|
||||
kennung,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
kennung: string;
|
||||
className?: string;
|
||||
children: (anfasser: React.ReactNode) => React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: kennung });
|
||||
|
||||
const anfasser = (
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="Verschieben"
|
||||
aria-label="Verschieben"
|
||||
// touch-none, damit die Geste auf dem Anfasser nicht zum Bildlauf wird.
|
||||
className="flex size-11 shrink-0 cursor-grab touch-none items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-10"
|
||||
>
|
||||
<GripVertical className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={schiebeStil(transform, transition)}
|
||||
className={`${className ?? ""} ${isDragging ? "relative z-10 border-akzent opacity-90" : ""}`}
|
||||
>
|
||||
{children(anfasser)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Der zweite Weg: eine Position nach oben oder nach unten, ohne Ziehen. */
|
||||
export function VerschiebeKnoepfe({
|
||||
art,
|
||||
index,
|
||||
anzahl,
|
||||
aufSortiert,
|
||||
}: {
|
||||
art: string;
|
||||
index: number;
|
||||
anzahl: number;
|
||||
aufSortiert: (von: number, nach: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SymbolKnopf
|
||||
symbol={ChevronUp}
|
||||
beschriftung={`${art} nach oben`}
|
||||
aus={index === 0}
|
||||
aufKlick={() => aufSortiert(index, index - 1)}
|
||||
/>
|
||||
<SymbolKnopf
|
||||
symbol={ChevronDown}
|
||||
beschriftung={`${art} nach unten`}
|
||||
aus={index === anzahl - 1}
|
||||
aufKlick={() => aufSortiert(index, index + 1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import {
|
||||
Auswahlfeld,
|
||||
Kennzeichen,
|
||||
KlappKarte,
|
||||
Kontrollkasten,
|
||||
LoeschKnopf,
|
||||
OptionenFelder,
|
||||
TextKnopf,
|
||||
Textfeld,
|
||||
} from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import {
|
||||
SCHLUESSEL_REGEL,
|
||||
VARIABLEN_TYP_TEXT,
|
||||
doppelteSchluessel,
|
||||
schluesselFehler,
|
||||
type EditorVariable,
|
||||
} from "@/lib/editor-aufbau";
|
||||
import { unbenutzteVariablen } from "@/lib/platzhalter";
|
||||
import { GRENZEN, VARIABLEN_TYPEN } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Die Variablen einer Vorlage (#010).
|
||||
*
|
||||
* Sie werden beim Start eines Laufs abgefragt und fuellen die Platzhalter.
|
||||
* Die Schluesselregel steht einmal ueber der Liste statt unter jedem Feld — und
|
||||
* am Feld nur dann, wenn der Schluessel gerade dagegen verstoesst. Ein Hinweis,
|
||||
* der zwanzigmal untereinander steht, wird nicht mehr gelesen.
|
||||
*/
|
||||
function VariablenKarte({
|
||||
variable,
|
||||
index,
|
||||
anzahl,
|
||||
lesend,
|
||||
neu,
|
||||
fehler,
|
||||
unbenutzt,
|
||||
anfasser,
|
||||
aendern,
|
||||
loeschen,
|
||||
umsortieren,
|
||||
}: {
|
||||
variable: EditorVariable;
|
||||
index: number;
|
||||
anzahl: number;
|
||||
lesend: boolean;
|
||||
neu: boolean;
|
||||
fehler: string | undefined;
|
||||
unbenutzt: boolean;
|
||||
anfasser: React.ReactNode;
|
||||
aendern: (teil: Partial<EditorVariable>) => void;
|
||||
loeschen: () => void;
|
||||
umsortieren: (von: number, nach: number) => void;
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(neu);
|
||||
const schluessel = variable.schluessel.trim();
|
||||
|
||||
return (
|
||||
<KlappKarte
|
||||
offen={offen}
|
||||
umschalten={() => setzeOffen((vorher) => !vorher)}
|
||||
anfasser={anfasser}
|
||||
kopf={
|
||||
<>
|
||||
<span className="mono shrink-0 text-xs text-nebentext">{index + 1}</span>
|
||||
{/* Zugeklappt muss ein fehlerhafter Schluessel auffallen, sonst sucht ihn niemand. */}
|
||||
{fehler ? (
|
||||
<TriangleAlert
|
||||
className="size-4 shrink-0 text-fehler"
|
||||
strokeWidth={1.75}
|
||||
aria-label="Fehler im Schlüssel"
|
||||
/>
|
||||
) : null}
|
||||
<code className={`mono shrink-0 text-xs ${schluessel === "" ? "text-nebentext" : ""}`}>
|
||||
{schluessel === "" ? "ohne Schlüssel" : `{{${schluessel}}}`}
|
||||
</code>
|
||||
{/*
|
||||
* Bei 390 px blieben von der Beschriftung zwei Zeichen uebrig („Be…“).
|
||||
* Der Schluessel ist das, wonach man sucht; die Beschriftung steht
|
||||
* eine Zeile tiefer, sobald die Karte offen ist.
|
||||
*/}
|
||||
<span className="hidden truncate text-nebentext sm:inline">{variable.beschriftung}</span>
|
||||
<Kennzeichen>{VARIABLEN_TYP_TEXT[variable.typ]}</Kennzeichen>
|
||||
</>
|
||||
}
|
||||
knoepfe={
|
||||
lesend ? null : (
|
||||
<>
|
||||
<VerschiebeKnoepfe art="Variable" index={index} anzahl={anzahl} aufSortiert={umsortieren} />
|
||||
<LoeschKnopf beschriftung="Variable löschen" aufLoeschen={loeschen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Textfeld
|
||||
beschriftung="Schlüssel"
|
||||
wert={variable.schluessel}
|
||||
setzen={(wert) => aendern({ schluessel: wert })}
|
||||
lesend={lesend}
|
||||
mono
|
||||
maxLaenge={40}
|
||||
vorschlag="vorname"
|
||||
{...(fehler ? { fehler } : {})}
|
||||
/>
|
||||
<Textfeld
|
||||
beschriftung="Beschriftung"
|
||||
wert={variable.beschriftung}
|
||||
setzen={(wert) => aendern({ beschriftung: wert })}
|
||||
lesend={lesend}
|
||||
vorschlag="Vorname"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="sm:max-w-48">
|
||||
<Auswahlfeld
|
||||
beschriftung="Typ"
|
||||
wert={variable.typ}
|
||||
werte={VARIABLEN_TYPEN}
|
||||
setzen={(typ) => aendern({ typ })}
|
||||
lesend={lesend}
|
||||
beschriftungen={VARIABLEN_TYP_TEXT}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:mt-6">
|
||||
<Kontrollkasten
|
||||
beschriftung="Pflichtangabe beim Start"
|
||||
wert={variable.pflicht}
|
||||
setzen={(pflicht) => aendern({ pflicht })}
|
||||
lesend={lesend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variable.typ === "AUSWAHL" ? (
|
||||
<OptionenFelder
|
||||
optionen={variable.optionen}
|
||||
setzen={(optionen) => aendern({ optionen })}
|
||||
lesend={lesend}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{schluessel !== "" && unbenutzt ? (
|
||||
<p className="text-xs text-nebentext">
|
||||
Wird nirgends verwendet. Das ist erlaubt — sie wird trotzdem beim Start abgefragt.
|
||||
</p>
|
||||
) : null}
|
||||
</KlappKarte>
|
||||
);
|
||||
}
|
||||
|
||||
export function VariablenBereich({
|
||||
variablen,
|
||||
verwendet,
|
||||
lesend,
|
||||
zuletztAngelegt,
|
||||
aendern,
|
||||
hinzufuegen,
|
||||
loeschen,
|
||||
umsortieren,
|
||||
}: {
|
||||
variablen: readonly EditorVariable[];
|
||||
/** Die im Aufbau tatsaechlich vorkommenden Platzhalter. */
|
||||
verwendet: readonly string[];
|
||||
lesend: boolean;
|
||||
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
||||
zuletztAngelegt: string | null;
|
||||
aendern: (kennung: string, teil: Partial<EditorVariable>) => void;
|
||||
hinzufuegen: () => void;
|
||||
loeschen: (kennung: string) => void;
|
||||
umsortieren: (von: number, nach: number) => void;
|
||||
}) {
|
||||
const doppelt = new Set(doppelteSchluessel(variablen));
|
||||
const unbenutzt = new Set(
|
||||
unbenutzteVariablen(
|
||||
verwendet,
|
||||
variablen.map((variable) => variable.schluessel.trim()).filter((schluessel) => schluessel !== ""),
|
||||
),
|
||||
);
|
||||
const voll = variablen.length >= GRENZEN.variablen;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Variablen</h2>
|
||||
<p className="mt-1 max-w-prose text-sm text-nebentext">
|
||||
Werden beim Start eines Laufs abgefragt und füllen die Platzhalter{" "}
|
||||
<code className="mono">{"{{schluessel}}"}</code> in Titeln, Hilfetexten und Befehlen.{" "}
|
||||
{SCHLUESSEL_REGEL}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{variablen.length === 0 ? (
|
||||
<p className="text-sm text-nebentext">
|
||||
Keine Variablen. Eine Vorlage ohne Variablen ist in Ordnung — die eingebauten Platzhalter stehen
|
||||
trotzdem zur Verfügung.
|
||||
</p>
|
||||
) : (
|
||||
<SortierBereich
|
||||
kennungen={variablen.map((variable) => variable.kennung)}
|
||||
art="Variable"
|
||||
lesend={lesend}
|
||||
aufSortiert={umsortieren}
|
||||
>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{variablen.map((variable, index) => {
|
||||
const schluessel = variable.schluessel.trim();
|
||||
const fehler = doppelt.has(schluessel)
|
||||
? `Den Schlüssel „${schluessel}“ gibt es zweimal.`
|
||||
: schluessel === ""
|
||||
? undefined
|
||||
: schluesselFehler(variable.schluessel);
|
||||
|
||||
return (
|
||||
<SortierZeile key={variable.kennung} kennung={variable.kennung} lesend={lesend}>
|
||||
{(anfasser) => (
|
||||
<VariablenKarte
|
||||
variable={variable}
|
||||
index={index}
|
||||
anzahl={variablen.length}
|
||||
lesend={lesend}
|
||||
neu={variable.kennung === zuletztAngelegt}
|
||||
fehler={fehler}
|
||||
unbenutzt={unbenutzt.has(schluessel)}
|
||||
anfasser={anfasser}
|
||||
aendern={(teil) => aendern(variable.kennung, teil)}
|
||||
loeschen={() => loeschen(variable.kennung)}
|
||||
umsortieren={umsortieren}
|
||||
/>
|
||||
)}
|
||||
</SortierZeile>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SortierBereich>
|
||||
)}
|
||||
|
||||
{lesend ? null : (
|
||||
<TextKnopf
|
||||
beschriftung={voll ? `Höchstens ${GRENZEN.variablen} Variablen` : "Variable hinzufügen"}
|
||||
aus={voll}
|
||||
aufKlick={hinzufuegen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
|
||||
import { platzhalterNachQuelle } from "@/lib/editor-aufbau";
|
||||
import { QUELLEN_TEXT, type PlatzhalterQuelle } from "@/lib/platzhalter";
|
||||
|
||||
/**
|
||||
* Welche Platzhalter zur Verfuegung stehen (#010).
|
||||
*
|
||||
* Eingeklappt, weil die Liste lang ist und nur gebraucht wird, wenn man einen
|
||||
* Platzhalter sucht. Gruppiert nach Quelle, damit sichtbar ist, woraus ein Wert
|
||||
* kommt — und was im Modus Intern leer bleibt.
|
||||
*/
|
||||
export function PlatzhalterHilfe({ variablen }: { variablen: readonly string[] }) {
|
||||
const gruppen = platzhalterNachQuelle();
|
||||
const quellen = Object.entries(QUELLEN_TEXT) as readonly [PlatzhalterQuelle, string][];
|
||||
|
||||
return (
|
||||
<details className="rounded-karte border border-linie bg-flaeche">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text md:min-h-12">
|
||||
Welche Platzhalter gibt es?
|
||||
</summary>
|
||||
|
||||
<div className="flex flex-col gap-5 px-4 pt-1 pb-4">
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
Ein Platzhalter steht als <code className="mono">{"{{schluessel}}"}</code> im Titel, im Hilfetext
|
||||
oder im Befehl und wird beim Abarbeiten ersetzt. Leerzeichen in den Klammern sind erlaubt.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Variablen dieser Vorlage</h3>
|
||||
{variablen.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-nebentext">Noch keine.</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{variablen.map((schluessel) => (
|
||||
<li key={schluessel}>
|
||||
<code className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-text">
|
||||
{`{{${schluessel}}}`}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quellen.map(([quelle, text]) => {
|
||||
const eintraege = gruppen.get(quelle) ?? [];
|
||||
if (eintraege.length === 0) return null;
|
||||
return (
|
||||
<div key={quelle}>
|
||||
<h3 className="text-sm font-semibold">{text}</h3>
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{eintraege.map((platzhalter) => (
|
||||
<li
|
||||
key={platzhalter.schluessel}
|
||||
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3"
|
||||
>
|
||||
<code className="mono shrink-0 text-xs text-akzent">
|
||||
{`{{${platzhalter.schluessel}}}`}
|
||||
</code>
|
||||
<span className="text-xs text-nebentext">
|
||||
{platzhalter.erklaerung}
|
||||
{platzhalter.nurDienstleistung ? (
|
||||
<>
|
||||
{" "}
|
||||
<Kennzeichen>nur im Modus Dienstleistung</Kennzeichen>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ExternalLink, Eye } from "lucide-react";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Kennzeichen } from "@/components/vorlagen/editor-felder";
|
||||
import {
|
||||
BEISPIEL_PASSWORT,
|
||||
STANDARD_SHELL,
|
||||
abschnittNummer,
|
||||
beispielwerte,
|
||||
mitBeispielwerten,
|
||||
verwendetePlatzhalter,
|
||||
type EditorAufbau,
|
||||
type EditorSchritt,
|
||||
} from "@/lib/editor-aufbau";
|
||||
|
||||
/**
|
||||
* Die Vorlage so, wie sie im Lauf aussieht (#010).
|
||||
*
|
||||
* Reine Darstellung: jedes Feld ist abgeschaltet, nichts wird gespeichert, und
|
||||
* die Platzhalter sind mit erfundenen Beispielwerten gefuellt. Das steht auch
|
||||
* oben dran — sonst haelt jemand den Beispielnamen fuer einen echten Datensatz.
|
||||
*
|
||||
* Fuer <code>generiertes_passwort</code> entsteht hier **kein** Passwort. Es wird
|
||||
* je Anzeige neu erzeugt und nirgends gespeichert (#012); eine Vorschau, die
|
||||
* eines erzeugte, waere der erste Ort, an dem eines an einer unerwarteten Stelle
|
||||
* steht.
|
||||
*/
|
||||
|
||||
const FELD_AUS = "min-h-11 w-full rounded-karte border border-linie bg-grund px-3 text-sm text-nebentext";
|
||||
|
||||
function SchrittFeld({ schritt, werte }: { schritt: EditorSchritt; werte: Record<string, string> }) {
|
||||
switch (schritt.typ) {
|
||||
case "HAKEN":
|
||||
return (
|
||||
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
|
||||
<input type="checkbox" disabled className="size-5 accent-akzent" />
|
||||
erledigt
|
||||
</label>
|
||||
);
|
||||
|
||||
case "TEXT":
|
||||
return schritt.konfig.mehrzeilig === true ? (
|
||||
<textarea disabled rows={3} className={`${FELD_AUS} py-2`} />
|
||||
) : (
|
||||
<input disabled className={FELD_AUS} />
|
||||
);
|
||||
|
||||
case "ZAHL":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" disabled className={`${FELD_AUS} sm:max-w-40`} />
|
||||
{schritt.konfig.einheit ? (
|
||||
<span className="text-sm text-nebentext">{schritt.konfig.einheit}</span>
|
||||
) : null}
|
||||
{schritt.konfig.min !== undefined || schritt.konfig.max !== undefined ? (
|
||||
<span className="text-xs text-nebentext">
|
||||
{schritt.konfig.min !== undefined ? `ab ${schritt.konfig.min}` : ""}
|
||||
{schritt.konfig.min !== undefined && schritt.konfig.max !== undefined ? " " : ""}
|
||||
{schritt.konfig.max !== undefined ? `bis ${schritt.konfig.max}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "AUSWAHL":
|
||||
return (
|
||||
<select disabled className={`${FELD_AUS} sm:max-w-72`}>
|
||||
<option>{schritt.konfig.optionen?.[0] ?? "— keine Option hinterlegt —"}</option>
|
||||
</select>
|
||||
);
|
||||
|
||||
case "DATUM":
|
||||
return <input type="date" disabled className={`${FELD_AUS} sm:max-w-48`} />;
|
||||
|
||||
case "BEFEHL":
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Kennzeichen>{schritt.konfig.shell ?? STANDARD_SHELL}</Kennzeichen>
|
||||
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs whitespace-pre-wrap">
|
||||
{mitBeispielwerten(schritt.konfig.befehl ?? "", werte) || "— kein Befehl hinterlegt —"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vorschau haengt an einem Schalter und wird nur dann gebaut, wenn sie
|
||||
* jemand sehen will.
|
||||
*
|
||||
* Zwei Gruende, beide gemessen: sie macht bei 390 px rund 1 500 px Seitenhoehe
|
||||
* aus, und sie rechnet bei **jedem Tastendruck** die ganze Vorlage samt Markdown
|
||||
* neu. Bei einer Vorlage mit 150 Schritten waere das bei jedem Buchstaben
|
||||
* spuerbar. Zugeklappt wird sie nicht nur ausgeblendet, sondern gar nicht erst
|
||||
* gerendert — sie haelt keinen Zustand, es geht also nichts verloren.
|
||||
*/
|
||||
/** Nur `http` und `https` – dieselbe Regel, die `SchrittSchema` beim Speichern anwendet. */
|
||||
function istWebAdresse(roh: string): boolean {
|
||||
const wert = roh.trim();
|
||||
return /^https?:\/\//iu.test(wert);
|
||||
}
|
||||
|
||||
export function Vorschau({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
|
||||
const [offen, setzeOffen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-karte border border-linie bg-flaeche p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setzeOffen((vorher) => !vorher)}
|
||||
aria-expanded={offen}
|
||||
className="flex min-h-11 items-center gap-3 text-left md:min-h-10"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-nebentext ${offen ? "" : "-rotate-90"}`}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Eye className="size-[18px] shrink-0 text-akzent" strokeWidth={1.75} aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold">Vorschau</h2>
|
||||
<span className="text-sm text-nebentext">mit Beispielwerten</span>
|
||||
</button>
|
||||
{offen ? <VorschauInhalt aufbau={aufbau} beispielDatum={beispielDatum} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VorschauInhalt({ aufbau, beispielDatum }: { aufbau: EditorAufbau; beispielDatum: string }) {
|
||||
const werte = beispielwerte(aufbau.variablen, beispielDatum);
|
||||
const mitPasswort = verwendetePlatzhalter(aufbau).includes("generiertes_passwort");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
So sieht die Vorlage im Lauf aus. Alle Platzhalter sind mit{" "}
|
||||
<span className="text-faellig">erfundenen Beispielwerten</span> gefüllt, die Felder sind
|
||||
abgeschaltet, und nichts davon wird gespeichert.
|
||||
</p>
|
||||
{mitPasswort ? (
|
||||
<p className="mt-2 max-w-prose text-sm text-nebentext">
|
||||
<code className="mono">{"{{generiertes_passwort}}"}</code> steht hier als{" "}
|
||||
<code className="mono">{BEISPIEL_PASSWORT}</code>. Das echte Passwort entsteht erst beim
|
||||
Abarbeiten, wird bei jeder Anzeige neu erzeugt und nirgends gespeichert.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{aufbau.variablen.length > 0 ? (
|
||||
<div className="rounded-karte border border-linie bg-flaeche-hoch p-3">
|
||||
<h3 className="text-sm font-semibold">Beim Start abgefragt</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{aufbau.variablen.map((variable) => (
|
||||
<label key={variable.kennung} className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">
|
||||
{variable.beschriftung || variable.schluessel || "ohne Beschriftung"}
|
||||
{variable.pflicht ? <span className="text-fehler"> *</span> : null}
|
||||
</span>
|
||||
<input disabled value={werte[variable.schluessel.trim()] ?? ""} className={FELD_AUS} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aufbau.abschnitte.length === 0 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Abschnitt — es gibt nichts abzuarbeiten.</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-5">
|
||||
{aufbau.abschnitte.map((abschnitt, index) => (
|
||||
<li key={abschnitt.kennung}>
|
||||
<h3 className="flex items-baseline gap-2 text-base font-semibold">
|
||||
<span className="zahl text-akzent">{abschnittNummer(index)}</span>
|
||||
<span className="text-nebentext">·</span>
|
||||
<span>{mitBeispielwerten(abschnitt.titel, werte) || "Ohne Titel"}</span>
|
||||
</h3>
|
||||
|
||||
<ul className="mt-3 flex flex-col gap-4">
|
||||
{abschnitt.schritte.map((schritt) => (
|
||||
<li key={schritt.kennung} className="flex flex-col gap-2 border-l border-linie pl-3">
|
||||
<p className="text-sm">
|
||||
{mitBeispielwerten(schritt.titel, werte) || "Ohne Titel"}
|
||||
{schritt.pflicht ? (
|
||||
<span className="text-fehler" title="Pflichtschritt">
|
||||
{" *"}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{schritt.hilfetext.trim() === "" ? null : (
|
||||
<Markdown text={mitBeispielwerten(schritt.hilfetext, werte)} />
|
||||
)}
|
||||
<SchrittFeld schritt={schritt} werte={werte} />
|
||||
{/*
|
||||
* Nur http und https werden verlinkt. Im Editor ist der
|
||||
* Doku-Link noch ungeprueft — das Schema sieht ihn erst
|
||||
* beim Speichern. Ein `data:`-Verweis in der Vorschau waere
|
||||
* die einzige Stelle, an der er anklickbar wuerde.
|
||||
*/}
|
||||
{!istWebAdresse(schritt.dokuUrl) ? null : (
|
||||
<a
|
||||
href={schritt.dokuUrl.trim()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-h-11 items-center gap-1.5 text-sm text-akzent underline underline-offset-2 md:min-h-10"
|
||||
>
|
||||
<ExternalLink className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Anleitung
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user