From f2379c0e28a1ecd83076a87e11d877053c7927ae Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sun, 31 May 2026 08:49:20 +0200 Subject: [PATCH] feat(stellungnahme): Tag-Pill-Filter, flache Gruppen, Sticky-Footer, Auto-Save, Auto-Focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../blocks/StellingnahmeGeneratorBlock.svelte | 174 +++++++++++++----- src/lib/iconify-mdi-subset.generated.json | 21 ++- 2 files changed, 148 insertions(+), 47 deletions(-) diff --git a/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte index cc4f941..90d5e40 100644 --- a/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte +++ b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte @@ -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>(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(); + 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 | 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(0); let selectedSlugs = $state>(new Set()); - let expandedGroups = $state>(new Set()); // empty = all collapsed let expandedItems = $state>(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)}`; {/if} {/each} -

Schritt {step + 1} von {TOTAL_STEPS} – {STEP_LABELS[step]}

+
+ {#if saveStatus === "saved"} + + + {/if} + Schritt {step + 1} von {TOTAL_STEPS} – {STEP_LABELS[step]} +
{#if step === 0} @@ -666,7 +734,7 @@ ${"=".repeat(64)}`; 2. Entwurf des Regionalplans Südwestthüringen zu verfassen. Sie wählen zuerst ortskonkrete Argumente für Ihr Gebiet, dann allgemeine Einwände.

-
+
+ {#if availableTags.length > 1} +
+ {#each availableTags as tag} + {@const active = activeTags.has(tag.slug)} + + {/each} + {#if activeTags.size > 0} + + {/if} +
+ {/if} {#if filteredGruppen.length === 0} -

Keine Argumente passen zu „{argFilter}".

+

+ Keine Argumente {argFilter ? `passen zu „${argFilter}"` : ""}{argFilter && activeTags.size > 0 ? " und " : ""}{activeTags.size > 0 ? "matchen die gewählten Themen" : ""}. +

{/if}
{#each filteredGruppen as group} {@const groupSelectedCount = group.items.filter((f) => selectedSlugs.has(f._slug)).length} {@const allSelected = groupSelectedCount === group.items.length} - {@const isExpanded = expandedGroups.has(group.slug) || argFilter.trim() !== ""}
- +
- +
@@ -854,7 +939,7 @@ ${"=".repeat(64)}`;
{/if} -
+
@@ -899,7 +985,7 @@ ${"=".repeat(64)}`;
- +
@@ -954,7 +1040,7 @@ ${"=".repeat(64)}`;
-
+