877 lines
39 KiB
Svelte
877 lines
39 KiB
Svelte
<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, "<").replace(/>/g, ">")}</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>
|