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

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