Files
windwiderstand/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte
T
Peter Meier ff383b8b3b
Deploy / verify (push) Successful in 55s
Deploy / deploy (push) Successful in 1m5s
fix(stellungnahme): mode const nicht in resetAll zuweisen
2026-05-27 16:02:53 +02:00

877 lines
39 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from "svelte";
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { StellingnahmeGeneratorBlockData } from "$lib/block-types";
import type { TextFragment, WindArea } from "$lib/windkarte";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
let {
block,
class: className = "",
}: {
block: StellingnahmeGeneratorBlockData;
class?: string;
} = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
// ── Resolved server data ─────────────────────────────────────────────────────
const windArea = $derived(
typeof block.windArea === "object" && block.windArea !== null
? (block.windArea as WindArea & { stellungnahme_kriterien?: TextFragment[] })
: null,
);
const kriterien = $derived(
(windArea?.stellungnahme_kriterien ?? []).filter(
(k): k is TextFragment => typeof k === "object" && k !== null && "_slug" in k,
),
);
const allgemeineArgumente = $derived(
(block.allgemeineArgumente ?? []) as TextFragment[],
);
// Tag used to fetch allgemeineArgumente — exclude from grouping categories
const fetchTagSlug = $derived(
typeof block.allgemeineArgumenteTag === "object" && block.allgemeineArgumenteTag !== null
? (block.allgemeineArgumenteTag as { _slug: string })._slug
: (block.allgemeineArgumenteTag as string | undefined) ?? "",
);
// Deduplicate: remove allgemeine fragments already covered by ortskonkrete (id, fallback _slug)
const ortskonkreteIds = $derived(new Set(kriterien.map((k) => k.id ?? k._slug)));
const allgemeineDeduped = $derived(
allgemeineArgumente.filter((f) => !ortskonkreteIds.has(f.id ?? f._slug)),
);
// Group allgemeine by first non-fetch tag; use tag.name as label
type Group = { slug: string; label: string; items: TextFragment[] };
const allgemeineGruppen = $derived.by((): Group[] => {
const map = new Map<string, Group>();
for (const f of allgemeineDeduped) {
const relevantTags = (f.tags ?? []).filter((t) => {
const slug = typeof t === "string" ? t : t._slug;
return slug !== fetchTagSlug;
});
let groupSlug = "__weitere";
let groupLabel = "Weitere Argumente";
if (relevantTags.length > 0) {
const first = relevantTags[0];
if (typeof first === "string") {
groupSlug = first;
// Slug → label: strip "tag-" prefix, replace hyphens with spaces, capitalize
groupLabel = first
.replace(/^tag-?/, "")
.replace(/-/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
} else {
groupSlug = first._slug;
groupLabel =
first.name ??
first._slug
.replace(/^tag-?/, "")
.replace(/-/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
}
}
if (!map.has(groupSlug)) {
map.set(groupSlug, { slug: groupSlug, label: groupLabel, items: [] });
}
map.get(groupSlug)!.items.push(f);
}
return Array.from(map.values());
});
// ── localStorage ────────────────────────────────────────────────────────────
const storageKey = `stellungnahme:${block._slug ?? block.id ?? "default"}`;
const historyKey = `${storageKey}:history`;
type HistoryEntry = { text: string; date: string; argCount: number };
let outputHistory = $state<HistoryEntry[]>([]);
let historyExpanded = $state(false);
let mounted = $state(false);
let hadSavedState = $state(false);
function saveState() {
try {
localStorage.setItem(storageKey, JSON.stringify({
selectedSlugs: Array.from(selectedSlugs),
persoenlicheEingabe,
absenderName,
absenderAnschrift,
absenderOrt,
}));
} catch {}
}
$effect(() => {
// track all persisted fields reactively
void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt];
if (mounted) saveState();
});
// ── Step state ───────────────────────────────────────────────────────────────
// 0 Gebietskontext | 1 Ortskonkret | 2 Allgemein | 3 Betroffenheit | 4 Ausgabe
type Step = 0 | 1 | 2 | 3 | 4;
let step = $state<Step>(0);
let selectedSlugs = $state<Set<string>>(new Set());
let expandedGroups = $state<Set<string>>(new Set()); // empty = all collapsed
let expandedItems = $state<Set<string>>(new Set()); // per-item text expand
function toggleItem(slug: string) {
const next = new Set(expandedItems);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
expandedItems = next;
}
const mode = "fertig";
let persoenlicheEingabe = $state("");
let absenderName = $state("");
let absenderAnschrift = $state("");
let absenderOrt = $state("");
let showOutput = $state(false);
function toggleSlug(slug: string) {
const next = new Set(selectedSlugs);
const nextExpanded = new Set(expandedItems);
if (next.has(slug)) {
next.delete(slug);
nextExpanded.delete(slug);
} else {
next.add(slug);
nextExpanded.add(slug);
}
selectedSlugs = next;
expandedItems = nextExpanded;
}
function toggleGroup(slug: string) {
const next = new Set(expandedGroups);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
expandedGroups = next;
}
function toggleGroupSelection(group: Group) {
const slugs = group.items.map((f) => f._slug);
const allSelected = slugs.every((s) => selectedSlugs.has(s));
const next = new Set(selectedSlugs);
for (const s of slugs) {
if (allSelected) next.delete(s);
else next.add(s);
}
selectedSlugs = next;
// Expand group when selecting so user sees what they picked
if (!allSelected) {
const nextGroups = new Set(expandedGroups);
nextGroups.add(group.slug);
expandedGroups = nextGroups;
}
}
// Combined selection for output — ortskonkrete first, then allgemeine
const selectedFragments = $derived([
...kriterien.filter((k) => selectedSlugs.has(k._slug)),
...allgemeineDeduped.filter((k) => selectedSlugs.has(k._slug)),
]);
const ortskonkreteSelectedCount = $derived(
kriterien.filter((k) => selectedSlugs.has(k._slug)).length,
);
const allgemeineSelectedCount = $derived(
allgemeineDeduped.filter((k) => selectedSlugs.has(k._slug)).length,
);
// ── Output ───────────────────────────────────────────────────────────────────
const RECIPIENT = `Regionale Planungsgemeinschaft Südwestthüringen
Regionale Planungsstelle
Karl-Liebknecht-Straße 4
98527 Suhl`;
const heute = $derived(
new Date().toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
);
const gebietsBez = $derived(
windArea
? `${windArea.gebiets_nr}${windArea.bezeichnung ? " " + windArea.bezeichnung : ""}`
: "",
);
function buildText(): string {
const sender = [absenderName.trim(), absenderAnschrift.trim(), absenderOrt.trim()]
.filter(Boolean)
.join("\n");
const betreff = `Einwendung zum Entwurf des Regionalplans Südwestthüringen (2. Entwurf)\nWindenergie-Vorranggebiet ${gebietsBez}`;
let body = "";
if (mode === "fertig") {
body += `ich erhebe Einwendung gegen die Ausweisung des Windenergie-Vorranggebiets ${gebietsBez} im 2. Entwurf des Regionalplans Südwestthüringen.\n\n`;
for (const f of selectedFragments) {
if (f.title) body += `${f.title}\n\n`;
if (f.text) body += `${stripMd(f.text)}\n\n`;
}
if (persoenlicheEingabe.trim())
body += `Persönliche Betroffenheit\n\n${persoenlicheEingabe.trim()}\n\n`;
body +=
"Ich fordere die Regionalplanung auf, das Vorranggebiet aus dem Regionalplan zu streichen oder zumindest einer erneuten, umfassenden Überprüfung zu unterziehen.\n\nMit freundlichen Grüßen";
} else {
body += `Gliederung meiner Einwendung (bitte in eigenen Worten ausformulieren):\n\n`;
let i = 1;
for (const f of selectedFragments) {
body += `${i}. ${f.title ?? f._slug.replace(/_/g, " ")}\n`;
if (f.bulletPoints?.length)
for (const bp of f.bulletPoints) body += ` - ${bp}\n`;
body += "\n";
i++;
}
if (persoenlicheEingabe.trim())
body += `${i}. Persönliche Betroffenheit\n ${persoenlicheEingabe.trim()}\n\n`;
body += "Ich fordere die Regionalplanung auf, das Vorranggebiet aus dem Regionalplan zu streichen oder zumindest einer erneuten, umfassenden Überprüfung zu unterziehen.\n\nMit freundlichen Grüßen";
}
return [sender, "", heute, "", "An:", RECIPIENT, "", "Betreff: " + betreff.split("\n")[0], betreff.split("\n")[1] ?? "", "", "Sehr geehrte Damen und Herren,", "", body.trimEnd()]
.join("\n")
.replace(/\n{3,}/g, "\n\n");
}
let sectionEl: HTMLElement | undefined;
function goTo(target: Step) {
// Skip step 1 when no ortskonkrete arguments exist
let s = target;
if (s === 1 && kriterien.length === 0) {
s = (target > step ? 2 : 0) as Step;
}
step = s;
setTimeout(() => sectionEl?.scrollIntoView({ behavior: "smooth", block: "start" }), 0);
}
let outputText = $state("");
let copyFeedback = $state("");
function generateOutput() {
outputText = buildText();
showOutput = true;
const entry: HistoryEntry = {
text: outputText,
date: new Date().toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }),
argCount: selectedFragments.length,
};
outputHistory = [entry, ...outputHistory.filter((h) => h.text !== outputText)].slice(0, 5);
try { localStorage.setItem(historyKey, JSON.stringify(outputHistory)); } catch {}
}
function clearHistory() {
outputHistory = [];
try { localStorage.removeItem(historyKey); } catch {}
}
function resetAll() {
selectedSlugs = new Set();
expandedItems = new Set();
expandedGroups = new Set();
persoenlicheEingabe = "";
absenderName = "";
absenderAnschrift = "";
absenderOrt = "";
showOutput = false;
outputText = "";
hadSavedState = false;
try { localStorage.removeItem(storageKey); } catch {}
}
function downloadText() {
const blob = new Blob([outputText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `einwendung-${windArea?.gebiets_nr ?? "stellungnahme"}.txt`;
a.click();
URL.revokeObjectURL(url);
}
async function copyText() {
try {
await navigator.clipboard.writeText(outputText);
copyFeedback = "Kopiert!";
setTimeout(() => (copyFeedback = ""), 2000);
} catch {
copyFeedback = "Kopieren fehlgeschlagen";
}
}
function printText() {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(
`<html><head><title>Stellungnahme</title><style>
body { font-family: serif; font-size: 13pt; line-height: 1.6; margin: 2cm; white-space: pre-wrap; }
@media print { body { margin: 2cm; } }
</style></head><body>${outputText.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body></html>`,
);
w.document.close();
w.print();
}
function md(text: string | undefined): string {
if (!text) return "";
return marked.parse(text) as string;
}
function stripMd(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
.trim();
}
const STEP_LABELS = ["Gebiet", "Ortskonkret", "Allgemein", "Betroffenheit", "Ausgabe"] as const;
const TOTAL_STEPS = 5;
// ── Deadline ─────────────────────────────────────────────────────────────────
const deadlineDate = $derived(block.deadline ? new Date(block.deadline) : null);
const deadlineDays = $derived.by(() => {
if (!deadlineDate) return null;
const now = new Date(); now.setHours(0, 0, 0, 0);
const d = new Date(deadlineDate); d.setHours(0, 0, 0, 0);
return Math.ceil((d.getTime() - now.getTime()) / 86_400_000);
});
const deadlineFormatted = $derived(
deadlineDate?.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }) ?? "",
);
// ── Mini-map ─────────────────────────────────────────────────────────────────
const CMS_BASE = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
let mapAreaData = $state<WindArea[]>([]);
let mapReady = $state(false);
let mapActive = $state(false);
const MapComponent = browser
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
: null;
// ── Hash navigation ──────────────────────────────────────────────────────────
function parseStepHash(hash: string): Step | null {
const m = hash.match(/^#step-(\d+)$/);
if (!m) return null;
const n = (parseInt(m[1], 10) - 1) as Step;
return n >= 0 && n < TOTAL_STEPS ? n : null;
}
// Sync step → hash (only when hash doesn't already match)
$effect(() => {
if (typeof window === "undefined") return;
const target = `#step-${step + 1}`;
if (location.hash !== target) location.hash = target;
});
onMount(() => {
// Restore persisted state
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const s = JSON.parse(saved);
if (Array.isArray(s.selectedSlugs)) selectedSlugs = new Set(s.selectedSlugs);
if (typeof s.persoenlicheEingabe === "string") persoenlicheEingabe = s.persoenlicheEingabe;
if (typeof s.absenderName === "string") absenderName = s.absenderName;
if (typeof s.absenderAnschrift === "string") absenderAnschrift = s.absenderAnschrift;
if (typeof s.absenderOrt === "string") absenderOrt = s.absenderOrt;
if ((s.selectedSlugs?.length ?? 0) > 0 || s.absenderName || s.persoenlicheEingabe) {
hadSavedState = true;
}
}
const savedHistory = localStorage.getItem(historyKey);
if (savedHistory) outputHistory = JSON.parse(savedHistory);
} catch {}
mounted = true;
const parsed = parseStepHash(location.hash);
if (parsed !== null) step = parsed;
const onHashChange = () => {
const p = parseStepHash(location.hash);
if (p !== null && p !== step) step = p;
};
window.addEventListener("hashchange", onHashChange);
// Fetch wind_area with geometry for mini-map
if (windArea?._slug) {
fetch(`${CMS_BASE}/api/content/wind_area/${windArea._slug}`, {
signal: AbortSignal.timeout(6_000),
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) mapAreaData = [data];
})
.catch(() => {})
.finally(() => { mapReady = true; });
} else {
mapReady = true;
}
return () => window.removeEventListener("hashchange", onHashChange);
});
</script>
<section bind:this={sectionEl} class="{layoutClasses} {className}">
<!-- Deadline banner -->
{#if deadlineDays !== null && deadlineDays >= 0}
{@const urgent = deadlineDays <= 3}
{@const soon = deadlineDays <= 7}
<div class="mb-4 flex items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium
{urgent ? 'border-red-300 bg-red-50 text-red-800' : soon ? 'border-orange-300 bg-orange-50 text-orange-800' : 'border-gelb-300 bg-gelb-50 text-gelb-800'}">
<Icon icon="mdi:clock-alert-outline" class="size-5 shrink-0" />
<span>
Einwendungsfrist: <strong>{deadlineFormatted}</strong>
{#if deadlineDays === 0}<strong>heute letzter Tag!</strong>
{:else if deadlineDays === 1}<strong>morgen letzter Tag!</strong>
{:else}— noch {deadlineDays} Tage
{/if}
</span>
</div>
{/if}
<!-- Privacy notice -->
<div class="mb-4 flex items-start gap-2 rounded-lg border border-himmel-200 bg-himmel-50 px-4 py-3 text-sm text-himmel-800">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
<span><strong>Ihre Daten bleiben auf Ihrem Gerät.</strong> Name, Anschrift und alle Eingaben werden nicht an Server übertragen.</span>
</div>
<!-- Fortsetzen-Banner -->
{#if hadSavedState && step === 0}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-wald-300 bg-wald-50 px-4 py-3 text-sm">
<span class="text-wald-800"><Icon icon="mdi:content-save-outline" class="mr-1 inline size-4" />Letzte Sitzung wiederhergestellt.</span>
<div class="flex gap-2">
{#if outputHistory.length > 0}
<button class="rounded-md bg-wald-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-wald-800" onclick={() => goTo(4)}>Zur Ausgabe →</button>
{:else if selectedSlugs.size > 0}
<button class="rounded-md bg-wald-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-wald-800" onclick={() => goTo(3)}>Fortsetzen →</button>
{/if}
<button class="rounded-md border border-stein-300 px-3 py-1.5 text-xs text-stein-600 hover:bg-stein-50" onclick={resetAll}>Neu starten</button>
</div>
</div>
{/if}
{#if block.headline}
<h2 class="mb-2 text-2xl font-bold text-wald-900">{block.headline}</h2>
{/if}
{#if block.intro}
<div class="prose prose-wald mb-6 max-w-none text-stein-700">{@html md(block.intro)}</div>
{/if}
<!-- Step indicator -->
<div class="mb-8 flex items-center gap-1 text-xs font-medium">
{#each Array.from({ length: TOTAL_STEPS }, (_, i) => i) as s}
<button
class="flex h-7 w-7 items-center justify-center rounded-full border transition-colors
{s === step
? 'border-wald-700 bg-wald-700 text-white'
: s < step
? 'border-wald-400 bg-wald-100 text-wald-700'
: 'border-stein-300 bg-white text-stein-400'}"
onclick={() => { if (s <= step) goTo(s as Step); }}
disabled={s > step}
aria-current={s === step ? "step" : undefined}
title={STEP_LABELS[s]}
>{s + 1}</button>
{#if s < TOTAL_STEPS - 1}
<div class="h-px flex-1 {s < step ? 'bg-wald-400' : 'bg-stein-200'}"></div>
{/if}
{/each}
</div>
<!-- ── Step 0: Gebietskontext ─────────────────────────────────────────────── -->
{#if step === 0}
<div>
<h3 class="mb-4 text-lg font-semibold text-wald-800">Schritt 1 Ihr Vorranggebiet</h3>
{#if windArea}
<div class="mb-6 rounded-xl border border-wald-200 bg-wald-50 p-5">
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-wald-600">Vorranggebiet</div>
<div class="text-xl font-bold text-wald-900">{windArea.gebiets_nr}</div>
{#if windArea.bezeichnung}
<div class="mt-0.5 text-base text-wald-700">{windArea.bezeichnung}</div>
{/if}
<dl class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{#if windArea.gemeinden?.length}
<div class="col-span-full">
<dt class="text-stein-500">Gemeinden</dt>
<dd class="font-medium text-stein-800">{windArea.gemeinden.join(", ")}</dd>
</div>
{/if}
{#if windArea.flaeche_ha}
<div>
<dt class="text-stein-500">Fläche</dt>
<dd class="font-medium text-stein-800">{windArea.flaeche_ha.toLocaleString("de-DE")} ha</dd>
</div>
{/if}
{#if windArea.anlagen_geplant}
<div>
<dt class="text-stein-500">Anlagen geplant</dt>
<dd class="font-medium text-stein-800">{windArea.anlagen_geplant}</dd>
</div>
{/if}
{#if windArea.hoehe_max_m}
<div>
<dt class="text-stein-500">Max. Höhe</dt>
<dd class="font-medium text-stein-800">{windArea.hoehe_max_m} m</dd>
</div>
{/if}
{#if windArea.investor}
<div>
<dt class="text-stein-500">Investor</dt>
<dd class="font-medium text-stein-800">{windArea.investor}</dd>
</div>
{/if}
{#if windArea.status}
<div>
<dt class="text-stein-500">Status</dt>
<dd class="font-medium text-stein-800 capitalize">{windArea.status.replace(/_/g, " ")}</dd>
</div>
{/if}
</dl>
{#if windArea.notizen}
<div class="mt-4 border-t border-wald-200 pt-3 text-sm text-stein-700">{@html md(windArea.notizen)}</div>
{/if}
</div>
<!-- Mini-map -->
{#if browser && MapComponent && mapReady && mapAreaData.length > 0}
<div class="relative mb-6 overflow-hidden rounded-xl border border-stein-200 shadow-sm" style="height:320px">
{#await MapComponent then Map}
<Map
areas={mapAreaData}
initialGebietsNr={windArea.gebiets_nr}
hiddenStatuses={[]}
hiddenRings={["2km","5km"]}
fitBoundsMaxZoom={13}
onselect={() => {}}
/>
{/await}
{#if !mapActive}
<button
type="button"
onclick={() => (mapActive = true)}
class="absolute inset-0 z-[500] flex cursor-pointer flex-col items-center justify-center gap-2 bg-black/10 backdrop-blur-[1px]"
aria-label="Karte aktivieren"
>
<span class="rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-stein-700 shadow">
Klicken zum Aktivieren der Karte
</span>
</button>
{/if}
</div>
{:else if mapReady && mapAreaData.length === 0}
<!-- no geometry available, skip silently -->
{:else if !mapReady}
<div class="mb-6 flex h-40 items-center justify-center rounded-xl border border-stein-200 bg-stein-50 text-sm text-stein-400">
Karte wird geladen …
</div>
{/if}
{:else}
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
Kein Vorranggebiet konfiguriert.
</div>
{/if}
<p class="mb-6 text-sm text-stein-600">
Dieser Assistent hilft Ihnen, eine individuelle Einwendung für den
<strong>2. Entwurf des Regionalplans Südwestthüringen</strong> zu verfassen.
Sie wählen zuerst ortskonkrete Argumente für Ihr Gebiet, dann allgemeine Einwände.
</p>
<div class="flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
disabled={!windArea}
onclick={() => goTo(1)}
>Weiter zu den Argumenten →</button>
{#if selectedSlugs.size > 0 || absenderName}
<button class="text-xs text-stein-400 hover:text-stein-600" onclick={resetAll}>Neu starten</button>
{/if}
</div>
</div>
<!-- ── Step 1: Ortskonkrete Argumente ───────────────────────────────────── -->
{:else if step === 1}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">Schritt 2 Ortskonkrete Argumente</h3>
<p class="mb-5 text-sm text-stein-600">
Diese Argumente beziehen sich direkt auf das Gebiet <strong>{windArea?.gebiets_nr ?? ""}</strong>.
Wählen Sie die Punkte, die auf Ihre Situation zutreffen.
</p>
{#if kriterien.length > 0}
<div class="mb-6 space-y-2">
{#each kriterien as k}
<div class="rounded-lg border transition-colors {selectedSlugs.has(k._slug) ? 'border-wald-400 bg-wald-50' : 'border-stein-200 bg-white'}">
<div class="flex items-start gap-3 p-3">
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 rounded accent-wald-700"
checked={selectedSlugs.has(k._slug)}
onchange={() => toggleSlug(k._slug)}
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="cursor-pointer font-medium text-stein-800" onclick={() => toggleSlug(k._slug)}>{k.title ?? k._slug.replace(/_/g, " ")}</span>
{#if k.text}
<button
class="shrink-0 text-wald-500 hover:text-wald-800"
onclick={() => toggleItem(k._slug)}
aria-label={expandedItems.has(k._slug) ? "Einklappen" : "Ausklappen"}
><Icon icon={expandedItems.has(k._slug) ? "mdi:chevron-up" : "mdi:chevron-down"} class="size-4" /></button>
{/if}
</div>
</div>
</div>
{#if k.text && expandedItems.has(k._slug)}
<div class="border-t border-stein-100 px-3 pb-3 pt-2">
<div class="prose prose-sm prose-stein max-w-none text-sm text-stein-700">{@html md(k.text)}</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
Für dieses Gebiet sind noch keine ortskonkreten Argumente hinterlegt.
</div>
{/if}
<div class="flex items-center gap-3">
<button class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50" onclick={() => goTo(0)}> Zurück</button>
<button class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800" onclick={() => goTo(2)}>
Weiter{ortskonkreteSelectedCount > 0 ? ` (${ortskonkreteSelectedCount} gewählt)` : ""}
</button>
</div>
</div>
<!-- ── Step 2: Allgemeine Argumente ─────────────────────────────────────── -->
{:else if step === 2}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">Schritt 3 Grundsätzliche Einwände</h3>
<p class="mb-1 text-sm text-stein-600">
Welche allgemeinen Argumente teilen Sie? Diese gelten unabhängig vom konkreten Standort.
</p>
{#if ortskonkreteSelectedCount > 0}
<p class="mb-4 text-xs text-stein-400">
Bereits aus Schritt 2 übernommen: {ortskonkreteSelectedCount} ortskonkrete{ortskonkreteSelectedCount !== 1 ? "s Argument" : " Argumente"}.
</p>
{:else}
<p class="mb-4 text-xs text-stein-400">Aus Schritt 2: keine ortskonkreten Argumente ausgewählt.</p>
{/if}
{#if allgemeineGruppen.length > 0}
<div class="mb-6 space-y-2">
{#each allgemeineGruppen as group}
{@const groupSelectedCount = group.items.filter((f) => selectedSlugs.has(f._slug)).length}
{@const allSelected = groupSelectedCount === group.items.length}
{@const isExpanded = expandedGroups.has(group.slug)}
<div class="rounded-lg border {groupSelectedCount > 0 ? 'border-stein-400' : 'border-stein-200'} overflow-hidden">
<!-- Group header -->
<div class="flex items-center gap-2 bg-stein-50 px-3 py-2.5">
<button
class="flex flex-1 items-center gap-2 text-left"
onclick={() => toggleGroup(group.slug)}
aria-expanded={isExpanded}
>
<Icon icon={isExpanded ? "mdi:chevron-down" : "mdi:chevron-right"} class="size-4 shrink-0 text-stein-400" />
<span class="font-medium text-stein-800 text-sm">{group.label}</span>
<span class="ml-auto text-xs text-stein-500">
{groupSelectedCount > 0 ? `${groupSelectedCount}/` : ""}{group.items.length}
</span>
</button>
<button
class="rounded px-2 py-0.5 text-xs font-medium transition
{allSelected ? 'bg-stein-200 text-stein-700 hover:bg-stein-300' : 'bg-wald-100 text-wald-700 hover:bg-wald-200'}"
onclick={() => toggleGroupSelection(group)}
title={allSelected ? "Alle abwählen" : "Alle wählen"}
>
{allSelected ? "Alle ab" : "Alle"}
</button>
</div>
<!-- Group items -->
{#if isExpanded}
<div class="divide-y divide-stein-100">
{#each group.items as f}
<div class="transition-colors {selectedSlugs.has(f._slug) ? 'bg-wald-50' : 'bg-white'}">
<div class="flex items-start gap-3 px-3 py-2.5">
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 rounded accent-wald-700"
checked={selectedSlugs.has(f._slug)}
onchange={() => toggleSlug(f._slug)}
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="cursor-pointer text-sm font-medium text-stein-800" onclick={() => toggleSlug(f._slug)}>{f.title ?? f._slug.replace(/_/g, " ")}</span>
{#if f.text}
<button
class="shrink-0 text-wald-500 hover:text-wald-800"
onclick={() => toggleItem(f._slug)}
aria-label={expandedItems.has(f._slug) ? "Einklappen" : "Ausklappen"}
><Icon icon={expandedItems.has(f._slug) ? "mdi:chevron-up" : "mdi:chevron-down"} class="size-4" /></button>
{/if}
</div>
</div>
</div>
{#if f.text && expandedItems.has(f._slug)}
<div class="border-t border-stein-100 px-3 pb-3 pt-2">
<div class="prose prose-sm prose-stein max-w-none text-sm text-stein-700">{@html md(f.text)}</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
Keine allgemeinen Argumente hinterlegt.
</div>
{/if}
<div class="flex items-center gap-3">
<button class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50" onclick={() => goTo(1)}> Zurück</button>
<button
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
disabled={selectedFragments.length === 0}
onclick={() => goTo(3)}
>
Weiter ({selectedFragments.length} Argument{selectedFragments.length !== 1 ? "e" : ""}) →
</button>
</div>
</div>
<!-- ── Step 3: Persönliche Betroffenheit ─────────────────────────────────── -->
{:else if step === 3}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">Schritt 4 Persönliche Betroffenheit</h3>
<p class="mb-2 text-sm text-stein-600">
Beschreiben Sie kurz, wie Sie persönlich betroffen sind.
<strong>Wichtig:</strong> Individuelle Betroffenheit stärkt Ihre Einwendung erheblich
und macht sie von anderen unterscheidbar.
</p>
<p class="mb-4 text-xs text-stein-400">
Beispiel: Ich wohne 800 m vom geplanten Standort entfernt und bin Eigentümer von …
</p>
<textarea
class="w-full rounded-lg border border-stein-300 px-4 py-3 text-sm text-stein-800 focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
rows="6"
placeholder="Meine Betroffenheit: …"
bind:value={persoenlicheEingabe}
></textarea>
<p class="mt-1 text-xs text-stein-400">Bleibt auf Ihrem Gerät. Wird nicht übertragen.</p>
<div class="mt-5 flex items-center gap-3">
<button class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50" onclick={() => goTo(2)}> Zurück</button>
<button class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800" onclick={() => goTo(4)}>Weiter →</button>
</div>
</div>
<!-- ── Step 4: Absender + Ausgabe ────────────────────────────────────────── -->
{:else if step === 4}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">Schritt 5 Absender & Ausgabe</h3>
<p class="mb-4 text-sm text-stein-600">Ihre Daten werden nur lokal zur Texterstellung genutzt.</p>
<div class="mb-5 grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-name">Vor- und Nachname</label>
<input id="absender-name" type="text" class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" placeholder="Max Mustermann" bind:value={absenderName} />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-anschrift">Straße und Hausnummer</label>
<input id="absender-anschrift" type="text" class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" placeholder="Hauptstr. 1" bind:value={absenderAnschrift} />
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-ort">PLZ und Ort</label>
<input id="absender-ort" type="text" class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" placeholder="98529 Suhl" bind:value={absenderOrt} />
</div>
</div>
<div class="mb-5 rounded-lg border border-stein-200 bg-stein-50 p-3 text-xs text-stein-600">
<div class="mb-1 font-semibold text-stein-700">Einzureichen bei:</div>
<div class="whitespace-pre">{RECIPIENT}</div>
</div>
<div class="mb-6 flex gap-3">
<button
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
disabled={!absenderName.trim()}
onclick={generateOutput}
>Text erstellen</button>
<button class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50" onclick={() => goTo(3)}> Zurück</button>
</div>
{#if showOutput}
<div class="rounded-xl border border-wald-300 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-wald-200 px-4 py-3">
<span class="text-sm font-semibold text-wald-800">Ihre Einwendung</span>
<div class="flex gap-2">
<button class="rounded-md border border-stein-300 px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50" onclick={copyText}>{copyFeedback || "Kopieren"}</button>
<button class="rounded-md border border-stein-300 px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50" onclick={downloadText}>Herunterladen</button>
<button class="rounded-md border border-stein-300 px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50" onclick={printText}>Drucken</button>
</div>
</div>
<div class="overflow-x-auto whitespace-pre-wrap break-words px-5 py-4 font-sans text-sm leading-relaxed text-stein-800">{outputText}</div>
</div>
<div class="mt-4 rounded-lg border border-himmel-200 bg-himmel-50 px-4 py-3 text-sm text-himmel-800">
<strong>Hinweis:</strong> Bitte passen Sie den Text vor dem Einreichen individuell an.
Einwendungen mit persönlichen Formulierungen sind wirksamer als identische Kopien.
</div>
{/if}
<!-- History -->
{#if outputHistory.length > 0}
<div class="mt-6">
<button
class="flex w-full items-center justify-between rounded-lg border border-stein-200 bg-stein-50 px-4 py-2.5 text-sm font-medium text-stein-700 hover:bg-stein-100"
onclick={() => (historyExpanded = !historyExpanded)}
>
<span>Frühere Versionen ({outputHistory.length})</span>
<Icon icon={historyExpanded ? "mdi:chevron-up" : "mdi:chevron-down"} class="size-4" />
</button>
{#if historyExpanded}
<div class="mt-2 space-y-2">
{#each outputHistory as entry, i}
<div class="rounded-lg border border-stein-200 bg-white">
<div class="flex items-center justify-between border-b border-stein-100 px-4 py-2 text-xs text-stein-500">
<span>{entry.date} · {entry.argCount} Argument{entry.argCount !== 1 ? "e" : ""}</span>
<button
class="text-wald-600 hover:text-wald-800"
onclick={() => { outputText = entry.text; showOutput = true; }}
>Wiederherstellen</button>
</div>
<div class="max-h-40 overflow-y-auto whitespace-pre-wrap break-words px-4 py-3 font-sans text-xs text-stein-600">{entry.text.slice(0, 400)}{entry.text.length > 400 ? " …" : ""}</div>
</div>
{/each}
<button
class="text-xs text-stein-400 hover:text-stein-600"
onclick={clearHistory}
>Verlauf löschen</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</section>