feat(stellungnahme): Tag-Pill-Filter, flache Gruppen, Sticky-Footer, Auto-Save, Auto-Focus
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user