feat(stellungnahme): KI-Prompt, editierbarer Text, Stepper-Füllstand, DnD-Reihenfolge
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m17s

- KI-Prompt kopieren mit Ton/Haltung-Slidern und persönlichem Hinweis
- Output-Textarea editierbar (field-sizing: content, auto-height)
- Stepper zeigt Füllstand: Haken (komplett), gelb (leer), grau (unbesucht)
- Weiter-Buttons mit Tooltips bei leerem Schritt
- Auto-Generate beim Betreten von Step 4, live bei Eingabe-Änderungen
- Drag & Drop Umsortierung der Argumente in Step 4
- Step in localStorage persistiert (HMR-Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-02 22:13:11 +02:00
parent f2379c0e28
commit 6546f7d0a3
2 changed files with 208 additions and 20 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import { marked } from "$lib/markdown-safe";
@@ -198,18 +198,29 @@
argNotes,
openingOverride,
closingOverride,
step,
}));
} catch {}
}
$effect(() => {
void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt, argNotes, openingOverride, closingOverride];
void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt, argNotes, openingOverride, closingOverride, step];
if (mounted) {
saveState();
flashSaved();
}
});
$effect(() => {
if (mounted && step > maxStepReached) maxStepReached = step;
});
$effect(() => {
if (!mounted || step !== 4 || hasManualEdit || !absenderName.trim()) return;
outputText = buildText();
showOutput = true;
});
// ── Step state ───────────────────────────────────────────────────────────────
// 0 Gebietskontext | 1 Ortskonkret | 2 Allgemein | 3 Betroffenheit | 4 Ausgabe
type Step = 0 | 1 | 2 | 3 | 4;
@@ -285,11 +296,26 @@
selectedSlugs = next;
}
// 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)),
]);
// Custom order for drag & drop reordering
let fragmentOrder = $state<string[]>([]);
$effect(() => {
const allFrags = [...kriterien, ...allgemeineDeduped];
const activeSet = new Set(allFrags.filter(k => selectedSlugs.has(k._slug)).map(k => k._slug));
const current = untrack(() => fragmentOrder);
fragmentOrder = [
...current.filter(s => activeSet.has(s)),
...[...activeSet].filter(s => !current.includes(s)),
];
});
// Combined selection for output — respects drag order
const selectedFragments = $derived.by(() => {
const allFrags = [...kriterien, ...allgemeineDeduped];
return fragmentOrder
.map(slug => allFrags.find(k => k._slug === slug))
.filter(Boolean) as typeof kriterien;
});
const ortskonkreteSelectedCount = $derived(
kriterien.filter((k) => selectedSlugs.has(k._slug)).length,
@@ -362,8 +388,26 @@ ${"=".repeat(64)}`;
let outputText = $state("");
let copyFeedback = $state("");
let copyAiPromptFeedback = $state("");
let maxStepReached = $state(0);
let hasManualEdit = $state(false);
let aiTone = $state(3); // 1=sachlich, 5=emotional
let aiSharpness = $state(2); // 1=konstruktiv, 5=fordernd
let aiCustomNote = $state("");
const aiToneLabels = ["sehr sachlich & rational", "eher sachlich", "ausgewogen", "eher emotional", "sehr emotional & persönlich betroffen"];
const aiSharpnessLabels = ["konstruktiv & kooperativ", "moderat", "bestimmt & klar", "kritisch", "fordernd & scharf"];
const stepFilled = $derived([
!!windArea,
ortskonkreteSelectedCount > 0 || kriterien.length === 0,
allgemeineSelectedCount > 0,
persoenlicheEingabe.trim().length > 0,
absenderName.trim().length > 0,
]);
function generateOutput() {
hasManualEdit = false;
outputText = buildText();
showOutput = true;
const entry: HistoryEntry = {
@@ -399,6 +443,23 @@ ${"=".repeat(64)}`;
try { localStorage.removeItem(storageKey); } catch {}
}
let dragIdx = $state<number | null>(null);
let dragOverIdx = $state<number | null>(null);
function onDragStart(i: number) { dragIdx = i; }
function onDragOver(e: DragEvent, i: number) { e.preventDefault(); dragOverIdx = i; }
function onDragEnd() { dragIdx = null; dragOverIdx = null; }
function onDrop(i: number) {
if (dragIdx === null || dragIdx === i) { dragIdx = null; dragOverIdx = null; return; }
const order = [...fragmentOrder];
const [moved] = order.splice(dragIdx, 1);
order.splice(i, 0, moved);
fragmentOrder = order;
hasManualEdit = false;
dragIdx = null;
dragOverIdx = null;
}
function downloadText() {
const blob = new Blob([outputText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -419,6 +480,38 @@ ${"=".repeat(64)}`;
}
}
async function copyAiPrompt() {
const toneDesc = aiToneLabels[aiTone - 1];
const sharpnessDesc = aiSharpnessLabels[aiSharpness - 1];
const customPart = aiCustomNote.trim() ? `\nZusätzliche Hinweise von mir: ${aiCustomNote.trim()}\n` : "";
const personalPart = persoenlicheEingabe.trim()
? `\nMeine persönliche Betroffenheit (bitte besonders authentisch und individuell einbauen):\n${persoenlicheEingabe.trim()}\n`
: "";
const prompt = `Du bist ein Schreibassistent. Erstelle aus der folgenden Muster-Stellungnahme eine individuell formulierte Stellungnahme für mich.
Gewünschter Stil:
- Ton: ${toneDesc}
- Haltung/Schärfe: ${sharpnessDesc}
${personalPart}${customPart}
Variiere außerdem:
- Die Reihenfolge der Argumente
- Die konkreten Formulierungen (keine Sätze 1:1 übernehmen)
Behalte alle inhaltlichen Punkte und Argumente vollständig bei.
Muster-Stellungnahme:
---
${outputText}
---`;
try {
await navigator.clipboard.writeText(prompt);
copyAiPromptFeedback = "Kopiert!";
setTimeout(() => (copyAiPromptFeedback = ""), 2000);
} catch {
copyAiPromptFeedback = "Fehler";
}
}
function printText() {
const w = window.open("", "_blank");
if (!w) return;
@@ -480,9 +573,9 @@ ${"=".repeat(64)}`;
const n = (parseInt(m[1], 10) - 1) as Step;
return n >= 0 && n < TOTAL_STEPS ? n : null;
}
// Sync step → hash (only after mount to avoid overwriting hash before onMount reads it)
$effect(() => {
$effect(() => {
if (typeof window === "undefined" || !mounted) return;
const target = `#step-${step + 1}`;
if (location.hash !== target) location.hash = target;
@@ -504,6 +597,9 @@ ${"=".repeat(64)}`;
if (typeof s.closingOverride === "string") closingOverride = s.closingOverride;
if ((s.selectedSlugs?.length ?? 0) > 0 || s.absenderName || s.persoenlicheEingabe) {
hadSavedState = true;
}
if (typeof s.step === "number" && s.step >= 0 && s.step < TOTAL_STEPS) {
step = s.step as Step;
}
}
const savedHistory = localStorage.getItem(historyKey);
@@ -598,16 +694,24 @@ ${"=".repeat(64)}`;
<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-700 bg-wald-700 text-white'
: s <= maxStepReached && stepFilled[s]
? 'border-wald-500 bg-wald-100 text-wald-700'
: s <= maxStepReached
? 'border-erde-400 bg-erde-50 text-erde-600'
: 'border-stein-300 bg-white text-stein-400'}"
onclick={() => { if (s <= step) goTo(s as Step); }}
: 'border-stein-300 bg-white text-stein-400'}"
onclick={() => { if (s <= maxStepReached) goTo(s as Step); }}
disabled={s > maxStepReached}
aria-current={s === step ? "step" : undefined}
title={STEP_LABELS[s]}
title={STEP_LABELS[s]}
>
{#if s !== step && s <= maxStepReached && stepFilled[s]}
<Icon icon="mdi:check" class="size-3.5" />
{:else}
{s + 1}
{/if}
</button>
{#if s < TOTAL_STEPS - 1}
{#if s < TOTAL_STEPS - 1}
<div class="h-px flex-1 {s < maxStepReached ? (stepFilled[s] ? 'bg-wald-400' : 'bg-erde-300') : 'bg-stein-200'}"></div>
{/if}
{/each}
@@ -805,7 +909,11 @@ ${"=".repeat(64)}`;
{/if}
<div class="sticky bottom-2 z-10 flex items-center gap-3 rounded-lg border border-stein-200 bg-white/95 p-3 shadow-lg backdrop-blur sm:static sm:border-0 sm:bg-transparent sm:p-0 sm:shadow-none sm:backdrop-blur-none">
<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 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-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800"
onclick={() => goTo(2)}
title={ortskonkreteSelectedCount === 0 ? "Tipp: Mindestens ein Argument wählen stärkt Ihre Einwendung" : undefined}
>
Weiter{ortskonkreteSelectedCount > 0 ? ` (${ortskonkreteSelectedCount} gewählt)` : ""}
</button>
@@ -944,6 +1052,7 @@ ${"=".repeat(64)}`;
<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-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800"
onclick={() => goTo(3)}
title={selectedFragments.length === 0 ? "Tipp: Ohne Argumente wird die Einwendung sehr kurz" : undefined}
>
Weiter{selectedFragments.length > 0 ? ` (${selectedFragments.length} Argument${selectedFragments.length !== 1 ? "e" : ""})` : ""}
@@ -972,7 +1081,11 @@ ${"=".repeat(64)}`;
></textarea>
<p class="mt-1 text-xs text-stein-400">Bleibt auf Ihrem Gerät. Wird nicht übertragen.</p>
<div class="sticky bottom-2 z-10 mt-5 flex items-center gap-3 rounded-lg border border-stein-200 bg-white/95 p-3 shadow-lg backdrop-blur sm:static sm:border-0 sm:bg-transparent sm:p-0 sm:shadow-none sm:backdrop-blur-none">
<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 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-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800"
onclick={() => goTo(4)}
title={!persoenlicheEingabe.trim() ? "Tipp: Persönliche Betroffenheit stärkt Ihre Einwendung erheblich" : undefined}
>Weiter →</button>
</div>
</div>
@@ -997,6 +1110,32 @@ ${"=".repeat(64)}`;
<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>
{#if selectedFragments.length > 1}
<details class="mb-5 rounded-lg border border-stein-200 overflow-hidden">
<summary class="flex cursor-pointer items-center justify-between bg-stein-50 px-4 py-2.5 text-sm font-medium text-stein-700 hover:bg-stein-100 list-none">
<span>Reihenfolge der Argumente ({selectedFragments.length})</span>
<Icon icon="mdi:drag-vertical" class="size-4 text-stein-400" />
</summary>
<div class="divide-y divide-stein-100">
{#each selectedFragments as frag, i}
<div
draggable="true"
ondragstart={() => onDragStart(i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={() => onDrop(i)}
ondragend={onDragEnd}
class="flex cursor-grab items-center gap-3 px-4 py-2.5 text-sm transition-colors active:cursor-grabbing
{dragOverIdx === i && dragIdx !== i ? 'bg-wald-50 border-t-2 border-wald-400' : 'bg-white hover:bg-stein-50'}"
>
<Icon icon="mdi:drag-vertical" class="size-4 shrink-0 text-stein-400" />
<span class="flex-1 truncate text-stein-700">{frag.title ?? frag._slug.replace(/_/g, " ")}</span>
<span class="shrink-0 text-[10px] text-stein-400">{i + 1}</span>
</div>
{/each}
</div>
</details>
{/if}
<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>
@@ -1046,7 +1185,8 @@ ${"=".repeat(64)}`;
<button
class="btn-wiggle rounded-lg bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
disabled={!absenderName.trim()}
onclick={generateOutput}
onclick={generateOutput}
title={!absenderName.trim() ? "Bitte erst Namen eintragen" : hasManualEdit ? "Manuelle Änderungen verwerfen und neu generieren" : undefined}
>{hasManualEdit ? "↺ Neu generieren" : showOutput ? "↺ Aktualisieren" : "Text erstellen"}</button>
</div>
{#if !absenderName.trim()}
@@ -1067,10 +1207,55 @@ ${"=".repeat(64)}`;
<a href={mailtoLink} class="rounded-md border border-wald-400 bg-wald-50 px-2.5 py-1 text-[11px] font-medium text-wald-700 no-underline hover:bg-wald-100">{block.recipientEmail ? "Per E-Mail senden" : "Per E-Mail (an mich)"}</a>
{/if}
</div>
</div>
</div>
<textarea
bind:value={outputText}
spellcheck="false"
oninput={() => { hasManualEdit = true; }}
class="w-full resize-none bg-transparent px-5 py-4 font-sans text-sm leading-relaxed text-stein-800 focus:outline-none"
style="min-height: 40rem; field-sizing: content;"
></textarea>
{#if mailTooLong}
<div class="border-t border-stein-100 px-5 py-2 text-xs text-stein-500">Für E-Mail zu lang — bitte <strong>Kopieren</strong> und in die Mail einfügen.</div>
{/if}
<div class="border-t border-himmel-100 bg-himmel-50/40 px-4 py-3">
<p class="mb-2.5 text-[11px] font-semibold uppercase tracking-wide text-himmel-700">Mit KI individualisieren</p>
<div class="mb-2 grid gap-3 sm:grid-cols-2">
<div>
<div class="mb-1.5">
<label for="ai-tone" class="text-[11px] text-stein-600">Ton: <span class="font-semibold text-himmel-700">{aiToneLabels[aiTone - 1]}</span></label>
</div>
<div class="flex items-center gap-2 overflow-visible py-1 px-2">
<span class="shrink-0 text-[10px] text-stein-400">Sachlich</span>
<input id="ai-tone" type="range" min="1" max="5" bind:value={aiTone} class="h-3 w-full cursor-pointer accent-himmel-500" />
<span class="shrink-0 text-[10px] text-stein-400">Emotional</span>
</div>
</div>
<div>
<div class="mb-1.5">
<label for="ai-sharpness" class="text-[11px] text-stein-600">Haltung: <span class="font-semibold text-himmel-700">{aiSharpnessLabels[aiSharpness - 1]}</span></label>
</div>
<div class="flex items-center gap-2 overflow-visible py-1 px-2">
<span class="shrink-0 text-[10px] text-stein-400">Konstruktiv</span>
<input id="ai-sharpness" type="range" min="1" max="5" bind:value={aiSharpness} class="h-3 w-full cursor-pointer accent-himmel-500" />
<span class="shrink-0 text-[10px] text-stein-400">Fordernd</span>
</div>
</div>
</div>
<div class="mb-2.5">
<label for="ai-custom-note" class="mb-1 block text-[11px] text-stein-600">Persönlicher Hinweis <span class="text-stein-400">(optional)</span></label>
<textarea
id="ai-custom-note"
bind:value={aiCustomNote}
rows="2"
placeholder='z.B. „Ich bin Landwirt und direkt betroffen“ oder „Bitte sehr kurz halten“'
class="w-full resize-none rounded border border-himmel-200 bg-white px-2.5 py-1.5 text-[11px] text-stein-700 placeholder:text-stein-300 focus:border-himmel-400 focus:outline-none"
></textarea>
</div>
<button
class="rounded-md border border-himmel-400 bg-himmel-100 px-3 py-1.5 text-[11px] font-medium text-himmel-800 hover:bg-himmel-200"
onclick={copyAiPrompt}
>{copyAiPromptFeedback || "KI-Prompt kopieren"}</button>
</div>
</div>
<div class="mt-4 rounded-lg border border-himmel-200 bg-himmel-50 px-4 py-3 text-sm text-himmel-800">
@@ -123,6 +123,9 @@
"content-save-outline": {
"body": "<path fill=\"currentColor\" d=\"M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7zm2 16H5V5h11.17L19 7.83zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3M6 6h9v4H6z\"/>"
},
"check-circle": {
"body": "<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>"
},
"home-group": {
"body": "<path fill=\"currentColor\" d=\"M17 16h-2v6h-3v-5H8v5H5v-6H3l7-6zM6 2l4 4H9v3H7V6H5v3H3V6H2zm12 1l5 5h-1v4h-3V9h-2v3h-1.66L14 10.87V8h-1z\"/>"
},