feat(stellungnahme): KI-Prompt, editierbarer Text, Stepper-Füllstand, DnD-Reihenfolge
- 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:
@@ -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\"/>"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user