feat(stellungnahme): Einwendungs-Assistent für Windvorranggebiete
Deploy / verify (push) Successful in 59s
Deploy / deploy (push) Successful in 2m37s

5-Schritt-Wizard: Gebietskontext → Argumente wählen → Ausgabeform →
persönliche Betroffenheit → Absender + Ausgabe. Alle Eingaben bleiben
lokal im Browser (kein Server-Transfer). Fertigtext- und Leitfaden-Modus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-27 14:41:00 +02:00
parent d781b3140e
commit 6d0861bb0a
7 changed files with 771 additions and 1 deletions
+22
View File
@@ -448,3 +448,25 @@ export interface WindMapBlockData {
areas?: (string | { _slug: string;[key: string]: unknown })[];
layout?: BlockLayout;
}
import type { TextFragment, WindArea } from "./windkarte";
/** Stellungnahme-Generator (_type: "stellungnahme_generator").
* Platzierbare Instanz, rendert Einwendungs-Assistenten für ein Vorranggebiet. */
export interface StellingnahmeGeneratorBlockData {
_type?: "stellungnahme_generator";
_slug?: string;
id?: string;
/** Referenz auf wind_area; nach Resolve vollständiges WindArea-Objekt inkl.
* aufgelöster stellungnahme_kriterien (TextFragment[]). */
windArea?: string | (WindArea & {
stellungnahme_kriterien?: TextFragment[];
});
headline?: string;
intro?: string;
/** Referenz auf tag; nach Resolve: { _slug, name }. */
allgemeineArgumenteTag?: string | { _slug: string; name?: string };
/** Vom Resolver befüllt: allgemeine (gebietsunabhängige) Fragmente via Tag. */
allgemeineArgumente?: TextFragment[];
layout?: BlockLayout;
}
+73
View File
@@ -4,6 +4,8 @@ import {
getPostBySlug,
getTags,
getTextFragmentBySlug,
getTextFragmentsByTag,
getEntryBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
getCalendarItems,
@@ -760,3 +762,74 @@ export async function resolveSearchableTextBlocks(
}
}
}
function isStellingnahmeGeneratorBlock(item: unknown): boolean {
return (item as { _type?: string })?._type === "stellungnahme_generator";
}
/** Löst stellungnahme_generator-Blöcke auf:
* - windArea → vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien
* - allgemeineArgumenteTag → allgemeineArgumente[] via Tag-Filter
*/
export async function resolveStellingnahmeGeneratorBlocks(
layout: RowContentLayout | null | undefined,
): Promise<void> {
if (!layout) return;
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isStellingnahmeGeneratorBlock(item)) continue;
const block = item as Record<string, unknown>;
// Resolve windArea
const windAreaRef = block.windArea;
const windAreaSlug =
typeof windAreaRef === "string"
? windAreaRef
: (windAreaRef as { _slug?: string })?._slug ?? "";
if (windAreaSlug) {
const windArea = await getEntryBySlug("wind_area", windAreaSlug, {
locale: "de",
resolve: ["stellungnahme_kriterien"],
depth: 2,
});
if (windArea) {
const kriterienRaw =
(windArea as { stellungnahme_kriterien?: unknown[] })
.stellungnahme_kriterien ?? [];
// Fragmente einzeln laden um bulletPoints zu bekommen (nicht im Basis-Resolve)
const kriterien = await Promise.all(
kriterienRaw.map(async (k) => {
const slug =
typeof k === "string"
? k
: (k as { _slug?: string })?._slug ?? "";
if (!slug) return null;
return getTextFragmentBySlug(slug, { locale: "de" });
}),
);
(windArea as Record<string, unknown>).stellungnahme_kriterien =
kriterien.filter(Boolean);
block.windArea = windArea;
}
}
// Resolve allgemeineArgumente via Tag
const tagRef = block.allgemeineArgumenteTag;
const tagSlug =
typeof tagRef === "string"
? tagRef
: (tagRef as { _slug?: string })?._slug ?? "";
if (tagSlug) {
block.allgemeineArgumente = await getTextFragmentsByTag(tagSlug, {
locale: "de",
});
}
}
}
}
+26
View File
@@ -749,6 +749,32 @@ export async function getTextFragmentBySlug(
});
}
/** Alle Text-Fragmente, die einen bestimmten Tag (Slug) tragen. */
export async function getTextFragmentsByTag(
tagSlug: string,
options?: { locale?: string },
): Promise<TextFragmentEntry[]> {
const key = `text_fragment:tag:${tagSlug}:${options?.locale ?? ""}`;
return cached("text_fragment", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/text_fragment`);
url.searchParams.set("_per_page", "500");
url.searchParams.set("_resolve", "tags");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await cmsFetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: TextFragmentEntry[] };
const items = data.items ?? [];
return items.filter((f) => {
const tags = (f as unknown as { tags?: unknown[] }).tags ?? [];
return tags.some((t) => {
if (typeof t === "string") return t === tagSlug;
return (t as { _slug?: string })?._slug === tagSlug;
});
});
});
}
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
export type TopBannerEntry = components["schemas"]["top_banner"];
+6
View File
@@ -44,6 +44,7 @@
DeadlineBannerBlockData,
LiveStrommixBlockData,
WindMapBlockData,
StellingnahmeGeneratorBlockData,
} from "$lib/block-types";
let {
@@ -104,6 +105,11 @@
{/await}
{:else if type === "wind_map"}
<WindkarteBlock block={block as WindMapBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
<Comp block={block as StellingnahmeGeneratorBlockData} />
{/await}
{:else}
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
Unbekannter Block: <code>{type}</code>
@@ -0,0 +1,640 @@
<script lang="ts">
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";
let {
block,
class: className = "",
}: {
block: StellingnahmeGeneratorBlockData;
class?: string;
} = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
// Resolved data from server
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[],
);
// ── Step state ──────────────────────────────────────────────────────────────
type Step = 0 | 1 | 2 | 3 | 4;
let step = $state<Step>(0);
// Step 1: selected argument slugs
let selectedSlugs = $state<Set<string>>(new Set());
// Step 2: mode
let mode = $state<"fertig" | "leitfaden">("fertig");
// Step 3: personal statement
let persoenlicheEingabe = $state("");
// Step 4: sender
let absenderName = $state("");
let absenderAnschrift = $state("");
let absenderOrt = $state("");
let showOutput = $state(false);
// ── Helpers ─────────────────────────────────────────────────────────────────
function slugLabel(slug: string) {
return slug.replace(/_/g, " ");
}
function fragmentTags(f: TextFragment): string[] {
return (f.tags ?? []).map((t) =>
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
).filter(Boolean);
}
// Group kriterien by first tag (ortskonkret) vs. allgemeine
const ortskonkreteKriterien = $derived(kriterien);
const allgemeineArgList = $derived(allgemeineArgumente);
// Pre-select all ortskonkrete on mount
$effect(() => {
const initial = new Set<string>();
for (const k of kriterien) {
initial.add(k._slug);
}
selectedSlugs = initial;
});
function toggleSlug(slug: string) {
const next = new Set(selectedSlugs);
if (next.has(slug)) {
next.delete(slug);
} else {
next.add(slug);
}
selectedSlugs = next;
}
// Build selected fragments in order: ortskonkret first, then allgemeine
const selectedFragments = $derived([
...ortskonkreteKriterien.filter((k) => selectedSlugs.has(k._slug)),
...allgemeineArgList.filter((k) => selectedSlugs.has(k._slug)),
]);
// ── Output generation ────────────────────────────────────────────────────────
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") {
// Fließtext: Einleitung + Argumente ausformuliert
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 += `${f.text.trim()}\n\n`;
if (f.bulletPoints?.length) {
for (const bp of f.bulletPoints) {
body += `- ${bp}\n`;
}
body += "\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\n";
body += "Mit freundlichen Grüßen";
} else {
// Leitfaden: Stichpunkte als Gliederung
body += `Betreff: Einwendung gegen Windenergie-Vorranggebiet ${gebietsBez}\n\n`;
body += "Gliederung meiner Einwendung (bitte in eigenen Worten ausformulieren):\n\n";
let i = 1;
for (const f of selectedFragments) {
body += `${i}. ${f.title ?? slugLabel(f._slug)}\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`;
}
}
const lines = [
sender,
"",
heute,
"",
"An:",
RECIPIENT,
"",
"Betreff: " + betreff.split("\n")[0],
betreff.split("\n")[1] ?? "",
"",
"Sehr geehrte Damen und Herren,",
"",
body.trimEnd(),
];
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}
let outputText = $state("");
function generateOutput() {
outputText = buildText();
showOutput = true;
}
async function copyText() {
try {
await navigator.clipboard.writeText(outputText);
copyFeedback = "Kopiert!";
setTimeout(() => (copyFeedback = ""), 2000);
} catch {
copyFeedback = "Kopieren fehlgeschlagen";
}
}
let copyFeedback = $state("");
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: 12pt; max-width: 700px; margin: 40px auto; white-space: pre-wrap; }
@media print { body { margin: 0; } }
</style></head><body><pre>${outputText.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre></body></html>`,
);
w.document.close();
w.print();
}
// ── Markdown helper ──────────────────────────────────────────────────────────
function md(text: string | undefined): string {
if (!text) return "";
return marked.parse(text) as string;
}
</script>
<section class="{layoutClasses} {className}">
<!-- Privacy notice -->
<div class="mb-6 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>
<!-- Headline / Intro -->
{#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 ([0, 1, 2, 3, 4] as const) 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) step = s; }}
disabled={s > step}
aria-current={s === step ? "step" : undefined}
title={["Gebiet", "Argumente", "Modus", "Betroffenheit", "Ausgabe"][s]}
>{s + 1}</button>
{#if s < 4}
<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>
{:else}
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
Kein Vorranggebiet konfiguriert. Bitte Seite im CMS einrichten.
</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.
Im nächsten Schritt wählen Sie die Argumente, die auf Ihre Situation zutreffen.
</p>
<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={() => (step = 1)}
>
Weiter zu den Argumenten →
</button>
</div>
<!-- ── Step 1: Argumente wählen ──────────────────────────────────────── -->
{:else if step === 1}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">
Schritt 2 Argumente wählen
</h3>
<p class="mb-5 text-sm text-stein-600">
Wählen Sie alle Punkte, die auf Ihr Gebiet und Ihre Situation zutreffen.
Ortskonkrete Argumente sind vorausgewählt.
</p>
{#if ortskonkreteKriterien.length > 0}
<div class="mb-6">
<div class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-wald-700">
<span class="h-1.5 w-1.5 rounded-full bg-wald-600"></span>
Ortskonkrete Argumente
</div>
<div class="space-y-2">
{#each ortskonkreteKriterien as k}
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors
{selectedSlugs.has(k._slug) ? 'border-wald-400 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
<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>
<div class="font-medium text-stein-800">{k.title ?? slugLabel(k._slug)}</div>
{#if k.bulletPoints?.length}
<ul class="mt-1 space-y-0.5 text-xs text-stein-500">
{#each k.bulletPoints.slice(0, 2) as bp}
<li> {bp}</li>
{/each}
{#if k.bulletPoints.length > 2}
<li class="text-stein-400">+ {k.bulletPoints.length - 2} weitere …</li>
{/if}
</ul>
{/if}
</div>
</label>
{/each}
</div>
</div>
{/if}
{#if allgemeineArgList.length > 0}
<div class="mb-6">
<div class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-stein-500">
<span class="h-1.5 w-1.5 rounded-full bg-stein-400"></span>
Allgemeine Argumente
</div>
<div class="space-y-2">
{#each allgemeineArgList as k}
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors
{selectedSlugs.has(k._slug) ? 'border-stein-400 bg-stein-50' : 'border-stein-200 bg-white hover:border-stein-300'}">
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 rounded accent-stein-600"
checked={selectedSlugs.has(k._slug)}
onchange={() => toggleSlug(k._slug)}
/>
<div>
<div class="font-medium text-stein-800">{k.title ?? slugLabel(k._slug)}</div>
{#if k.bulletPoints?.length}
<ul class="mt-1 space-y-0.5 text-xs text-stein-500">
{#each k.bulletPoints.slice(0, 2) as bp}
<li> {bp}</li>
{/each}
{#if k.bulletPoints.length > 2}
<li class="text-stein-400">+ {k.bulletPoints.length - 2} weitere …</li>
{/if}
</ul>
{/if}
</div>
</label>
{/each}
</div>
</div>
{/if}
{#if ortskonkreteKriterien.length === 0 && allgemeineArgList.length === 0}
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
Noch keine Argumente im CMS 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={() => (step = 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 disabled:opacity-40"
disabled={selectedSlugs.size === 0}
onclick={() => (step = 2)}
>
Weiter ({selectedSlugs.size} Argument{selectedSlugs.size !== 1 ? "e" : ""}) →
</button>
</div>
</div>
<!-- ── Step 2: Modus ─────────────────────────────────────────────────── -->
{:else if step === 2}
<div>
<h3 class="mb-1 text-lg font-semibold text-wald-800">
Schritt 3 Ausgabeform
</h3>
<p class="mb-5 text-sm text-stein-600">
Wie soll Ihre Einwendung aufgebaut sein?
</p>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer flex-col gap-2 rounded-xl border-2 p-4 transition-colors
{mode === 'fertig' ? 'border-wald-600 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
<input type="radio" class="sr-only" name="mode" value="fertig" bind:group={mode} />
<div class="flex items-center justify-between">
<span class="font-semibold text-wald-900">Fertigtext</span>
{#if mode === "fertig"}
<span class="text-wald-600"></span>
{/if}
</div>
<p class="text-sm text-stein-600">
Ausformulierter Brieftext auf Basis der gewählten Argumente.
Ideal als Grundlage zum Anpassen und Ergänzen.
</p>
</label>
<label class="flex cursor-pointer flex-col gap-2 rounded-xl border-2 p-4 transition-colors
{mode === 'leitfaden' ? 'border-wald-600 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
<input type="radio" class="sr-only" name="mode" value="leitfaden" bind:group={mode} />
<div class="flex items-center justify-between">
<span class="font-semibold text-wald-900">Leitfaden</span>
{#if mode === "leitfaden"}
<span class="text-wald-600"></span>
{/if}
</div>
<p class="text-sm text-stein-600">
Gegliederte Stichpunkte. Schreiben Sie Ihren Text komplett selbst
empfohlen für individuelle Einwendungen.
</p>
</label>
</div>
<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={() => (step = 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"
onclick={() => (step = 3)}
>Weiter →</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={() => (step = 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={() => (step = 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-1 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>
<!-- Empfänger-Info -->
<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={() => (step = 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={printText}
>
Drucken / Speichern
</button>
</div>
</div>
<pre class="overflow-x-auto whitespace-pre-wrap break-words px-5 py-4 text-sm leading-relaxed text-stein-800 font-sans">{outputText}</pre>
</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.
Einreichfrist: {#if windArea?.stellungnahme && typeof windArea.stellungnahme === "object" && "slug" in windArea.stellungnahme}
siehe Verfahrensbekanntmachung
{:else}
bitte bei der Planungsstelle erfragen
{/if}.
</div>
{/if}
</div>
{/if}
</section>
+2 -1
View File
@@ -3,6 +3,7 @@ export type TextFragment = {
id?: string;
title?: string;
text?: string;
bulletPoints?: string[];
tags?: Array<{ _slug: string; name?: string } | string>;
};
@@ -17,7 +18,7 @@ export type WindArea = {
investor?: string;
gemeinden?: string[];
stellungnahme?: { _slug: string; slug?: string } | string | null;
stellungnahme_kriterien?: Array<{ _slug: string } | string> | null;
stellungnahme_kriterien?: Array<TextFragment | { _slug: string } | string> | null;
notizen?: string;
geometry?: GeoJSON.Geometry;
};
+2
View File
@@ -12,6 +12,7 @@ import {
resolveSearchableTextBlocks,
resolveCalendarBlocks,
resolveDeadlineBannerBlocks,
resolveStellingnahmeGeneratorBlocks,
} from '$lib/blog-utils';
import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
import { env } from '$env/dynamic/public';
@@ -64,6 +65,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
resolveSearchableTextBlocks(page, tagsMap),
resolveCalendarBlocks(page),
resolveDeadlineBannerBlocks(page),
resolveStellingnahmeGeneratorBlocks(page),
]);
sanitizeBlocks(page);