feat(stellungnahme): Einwendungs-Assistent für Windvorranggebiete
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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, "<").replace(/>/g, ">")}</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>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user