feat(stellungnahme): Tag-Pill-Filter, flache Gruppen, Sticky-Footer, Auto-Save, Auto-Focus
Deploy / verify (push) Successful in 1m1s
Deploy / deploy (push) Successful in 1m9s

Filter:
- Tag-Pill-Reihe in Step 2 oberhalb der Suche. Multi-Select, OR-Logik
  zwischen Pills, AND mit Text-Suche. Pills aus allen Nicht-Fetch-Tags
  der deduplizierten Fragmente, alphabetisch sortiert. "Filter
  zurücksetzen" erscheint bei ≥1 aktivem Pill. Empty-State erklärt
  was nicht matcht (Text/Pills/beides).

Akkordeons raus (Variante B):
- Gruppen-Header bleiben als sichtbare Trenner (Label + Anzahl + "Alle"-
  Toggle), aber Click-to-Expand-Mechanik weg — Items immer offen.
  expandedGroups + toggleGroup als dead code entfernt.

Bedienkomfort:
- Sticky Mobile-Footer für alle 5 Step-Nav-Rows: bottom-2 mit
  weißem BG + Shadow + Backdrop-Blur. Auf Desktop unverändert (sm:static).
- Auto-Save-Indikator "✓ Gespeichert" rechts oben, blitzt 2s nach
  jeder Mutation. Pure UI — wir speichern eh schon in localStorage.
- Auto-Focus per Svelte-Action beim Step-Wechsel: Step 2 → Such-Input,
  Step 3 → Betroffenheit-Textarea, Step 4 → Name-Input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-31 08:49:20 +02:00
parent 77fee9b1c2
commit f2379c0e28
2 changed files with 148 additions and 47 deletions
@@ -89,18 +89,74 @@
// Quick-filter for allgemeine arguments (matches title / text / bulletPoints)
let argFilter = $state("");
// Tag-Pill-Filter (OR-Logik): wenn ≥1 Tag aktiv, matchen nur Fragmente
// mit mindestens einem aktiven Tag. Kombiniert mit argFilter via AND.
let activeTags = $state<Set<string>>(new Set());
function tagLabel(slug: string, name?: string): string {
if (name) return name;
return slug
.replace(/^tag-?/, "")
.replace(/-/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
}
function fragmentTagSlugs(f: TextFragment): string[] {
return (f.tags ?? [])
.map((t) => (typeof t === "string" ? t : t._slug))
.filter((s): s is string => !!s && s !== fetchTagSlug);
}
const availableTags = $derived.by((): { slug: string; label: string }[] => {
const map = new Map<string, string>();
for (const f of allgemeineDeduped) {
for (const t of f.tags ?? []) {
const slug = typeof t === "string" ? t : t._slug;
if (!slug || slug === fetchTagSlug) continue;
if (!map.has(slug)) {
const name = typeof t === "string" ? undefined : t.name;
map.set(slug, tagLabel(slug, name));
}
}
}
return Array.from(map.entries())
.map(([slug, label]) => ({ slug, label }))
.sort((a, b) => a.label.localeCompare(b.label, "de"));
});
function toggleTag(slug: string) {
const next = new Set(activeTags);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
activeTags = next;
}
function clearTagFilter() {
activeTags = new Set();
}
const filteredGruppen = $derived.by((): Group[] => {
const q = argFilter.trim().toLowerCase();
if (!q) return allgemeineGruppen;
const tagFilterActive = activeTags.size > 0;
if (!q && !tagFilterActive) return allgemeineGruppen;
return allgemeineGruppen
.map((g) => ({
...g,
items: g.items.filter((f) => {
const hay = [f.title, f.text, ...(f.bulletPoints ?? [])]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(q);
// Text-Match
if (q) {
const hay = [f.title, f.text, ...(f.bulletPoints ?? [])]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(q)) return false;
}
// Tag-Match (OR über aktive Tags)
if (tagFilterActive) {
const slugs = fragmentTagSlugs(f);
if (!slugs.some((s) => activeTags.has(s))) return false;
}
return true;
}),
}))
.filter((g) => g.items.length > 0);
@@ -115,6 +171,21 @@
let historyExpanded = $state(false);
let mounted = $state(false);
let hadSavedState = $state(false);
// Auto-Save-Indikator: idle → saving (kurz) → saved (2s sichtbar) → idle.
let saveStatus = $state<"idle" | "saving" | "saved">("idle");
let savedHideTimer: ReturnType<typeof setTimeout> | null = null;
function flashSaved() {
saveStatus = "saved";
if (savedHideTimer) clearTimeout(savedHideTimer);
savedHideTimer = setTimeout(() => {
saveStatus = "idle";
}, 2000);
}
// Svelte-Action: fokussiert das Element beim Mount (Step-Wechsel).
function autofocus(node: HTMLElement) {
setTimeout(() => node.focus({ preventScroll: true }), 0);
}
function saveState() {
try {
@@ -133,7 +204,10 @@
$effect(() => {
void [selectedSlugs, persoenlicheEingabe, absenderName, absenderAnschrift, absenderOrt, argNotes, openingOverride, closingOverride];
if (mounted) saveState();
if (mounted) {
saveState();
flashSaved();
}
});
// ── Step state ───────────────────────────────────────────────────────────────
@@ -142,7 +216,6 @@
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) {
@@ -201,13 +274,6 @@
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));
@@ -217,11 +283,6 @@
else next.add(s);
}
selectedSlugs = next;
// Expand on select, collapse on deselect
const nextGroups = new Set(expandedGroups);
if (allSelected) nextGroups.delete(group.slug);
else nextGroups.add(group.slug);
expandedGroups = nextGroups;
}
// Combined selection for output — ortskonkrete first, then allgemeine
@@ -325,7 +386,6 @@ ${"=".repeat(64)}`;
}
selectedSlugs = new Set();
expandedItems = new Set();
expandedGroups = new Set();
argNotes = {};
openingOverride = null;
closingOverride = null;
@@ -551,7 +611,15 @@ ${"=".repeat(64)}`;
<div class="h-px flex-1 {s < step ? 'bg-wald-400' : 'bg-stein-200'}"></div>
{/if}
{/each}
</div>
</div>
<div class="mb-6 flex items-center justify-end gap-3 text-xs">
{#if saveStatus === "saved"}
<span class="flex items-center gap-1 text-wald-600 transition-opacity" aria-live="polite">
<Icon icon="mdi:check-circle" class="size-3.5" aria-hidden="true" />
Gespeichert
</span>
{/if}
<span class="text-stein-400">Schritt {step + 1} von {TOTAL_STEPS} {STEP_LABELS[step]}</span>
</div>
<!-- ── Step 0: Gebietskontext ─────────────────────────────────────────────── -->
@@ -666,7 +734,7 @@ ${"=".repeat(64)}`;
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>
</p>
<div class="sticky bottom-2 z-10 flex flex-wrap 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 bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
@@ -735,7 +803,7 @@ ${"=".repeat(64)}`;
Für dieses Gebiet sind noch keine ortskonkreten Argumente hinterlegt.
</div>
{/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 bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800" onclick={() => goTo(2)}>
@@ -767,30 +835,50 @@ ${"=".repeat(64)}`;
class="w-full rounded-lg border border-stein-300 py-2 pl-9 pr-3 text-sm text-stein-800 focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
placeholder="Argumente durchsuchen …"
bind:value={argFilter}
aria-label="Argumente durchsuchen"
use:autofocus
/>
</div>
{#if availableTags.length > 1}
<div class="mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Nach Themen filtern">
{#each availableTags as tag}
{@const active = activeTags.has(tag.slug)}
<button
type="button"
onclick={() => toggleTag(tag.slug)}
aria-pressed={active}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors
{active
? 'border-wald-700 bg-wald-700 text-white'
: 'border-stein-300 bg-white text-stein-600 hover:border-wald-400 hover:text-wald-700'}"
>{tag.label}</button>
{/each}
{#if activeTags.size > 0}
<button
type="button"
onclick={clearTagFilter}
class="ml-1 text-xs text-stein-400 hover:text-stein-700 underline"
>Filter zurücksetzen</button>
{/if}
</div>
{/if}
{#if filteredGruppen.length === 0}
{#if filteredGruppen.length === 0}
<p class="mb-6 rounded-lg border border-stein-200 bg-stein-50 px-4 py-3 text-sm text-stein-500">
Keine Argumente {argFilter ? `passen zu „${argFilter}"` : ""}{argFilter && activeTags.size > 0 ? " und " : ""}{activeTags.size > 0 ? "matchen die gewählten Themen" : ""}.
</p>
{/if}
<div class="mb-6 space-y-2">
{#each filteredGruppen as group}
{@const groupSelectedCount = group.items.filter((f) => selectedSlugs.has(f._slug)).length}
{@const allSelected = groupSelectedCount === group.items.length}
{@const allSelected = groupSelectedCount === group.items.length}
<div class="rounded-lg border {groupSelectedCount > 0 ? 'border-stein-400' : 'border-stein-200'} overflow-hidden">
<div class="rounded-lg border {groupSelectedCount > 0 ? 'border-stein-400' : 'border-stein-200'} overflow-hidden">
<!-- Group header (immer offen, dient als Trenner) -->
<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>
<div class="flex items-center gap-2 bg-stein-50 px-3 py-2.5">
<span class="flex flex-1 items-center gap-2 text-sm font-medium text-stein-800">
{group.label}
<span class="text-xs font-normal text-stein-500">
{groupSelectedCount > 0 ? `${groupSelectedCount}/` : ""}{group.items.length}
</span>
</span>
</span>
<button
class="rounded px-2 py-0.5 text-xs font-medium transition
@@ -801,9 +889,7 @@ ${"=".repeat(64)}`;
{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'}">
@@ -844,7 +930,6 @@ ${"=".repeat(64)}`;
{/if}
</div>
{/each}
</div>
</div>
</div>
{/each}
@@ -854,7 +939,7 @@ ${"=".repeat(64)}`;
Keine allgemeinen Argumente hinterlegt.
</div>
{/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(1)}> Zurück</button>
<button
@@ -882,9 +967,10 @@ ${"=".repeat(64)}`;
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}
use:autofocus
></textarea>
<p class="mt-1 text-xs text-stein-400">Bleibt auf Ihrem Gerät. Wird nicht übertragen.</p>
<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 bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800" onclick={() => goTo(4)}>Weiter →</button>
@@ -899,7 +985,7 @@ ${"=".repeat(64)}`;
<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>
<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} use:autofocus />
</div>
<div>
@@ -954,7 +1040,7 @@ ${"=".repeat(64)}`;
</div>
</details>
</div>
<div class="sticky bottom-2 z-10 mb-2 flex 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(3)}> Zurück</button>
<button