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
+18 -3
View File
@@ -123,6 +123,24 @@
"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\"/>"
},
"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\"/>"
},
"vector-square": {
"body": "<path fill=\"currentColor\" d=\"M2 2h6v2h8V2h6v6h-2v8h2v6h-6v-2H8v2H2v-6h2V8H2zm14 6V6H8v2H6v8h2v2h8v-2h2V8zM4 4v2h2V4zm14 0v2h2V4zM4 18v2h2v-2zm14 0v2h2v-2z\"/>"
},
"wind-turbine": {
"body": "<path fill=\"currentColor\" d=\"m13.33 11.67l2.88 2.91c1.41-1.42 0-2.83 0-2.83l-1.49-1.51c.18-.38.28-.8.28-1.24c0-1.05-.54-1.97-1.36-2.5L15 2.11c-1.91-.58-2.5 1.33-2.5 1.33l-.81 2.59c-1.23.13-2.23.97-2.56 2.15L4.67 9.63c.64 1.9 2.53 1.27 2.53 1.27l2.07-.67c.34.74.96 1.31 1.73 1.59V19s-2 0-2 2v1h6v-1s0-2-2-2v-7.18c.12-.04.23-.1.33-.15M10.5 9A1.5 1.5 0 0 1 12 7.5A1.5 1.5 0 0 1 13.5 9a1.5 1.5 0 0 1-1.5 1.5A1.5 1.5 0 0 1 10.5 9\"/>"
},
"arrow-expand-vertical": {
"body": "<path fill=\"currentColor\" d=\"M13 9v6h3l-4 4l-4-4h3V9H8l4-4l4 4zM4 2h16v2H4zm0 18h16v2H4z\"/>"
},
"domain": {
"body": "<path fill=\"currentColor\" d=\"M18 15h-2v2h2m0-6h-2v2h2m2 6h-8v-2h2v-2h-2v-2h2v-2h-2V9h8M10 7H8V5h2m0 6H8V9h2m0 6H8v-2h2m0 6H8v-2h2M6 7H4V5h2m0 6H4V9h2m0 6H4v-2h2m0 6H4v-2h2m6-10V3H2v18h20V7z\"/>"
},
"flag-outline": {
"body": "<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>"
},
"transmission-tower": {
"body": "<path fill=\"currentColor\" d=\"m8.28 5.45l-1.78-.9L7.76 2h8.47l1.27 2.55l-1.78.89L15 4H9zM18.62 8h-4.53l-.79-3h-2.6l-.79 3H5.38L4.1 10.55l1.79.89l.73-1.44h10.76l.72 1.45l1.79-.89zm-.85 14H15.7l-.24-.9L12 15.9l-3.47 5.2l-.23.9H6.23l2.89-11h2.07l-.36 1.35L12 14.1l1.16-1.75l-.35-1.35h2.07zm-6.37-7l-.9-1.35l-1.18 4.48zm3.28 3.12l-1.18-4.48l-.9 1.36z\"/>"
},
@@ -220,9 +238,6 @@
"tag-outline": {
"body": "<path fill=\"currentColor\" d=\"m21.41 11.58l-9-9A2 2 0 0 0 11 2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 .59 1.42l9 9A2 2 0 0 0 13 22a2 2 0 0 0 1.41-.59l7-7A2 2 0 0 0 22 13a2 2 0 0 0-.59-1.42M13 20l-9-9V4h7l9 9M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5\"/>"
},
"flag-outline": {
"body": "<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>"
},
"information-outline": {
"body": "<path fill=\"currentColor\" d=\"M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z\"/>"
}