feat(hikes): clickable tag chips + tag filter on the overview

Detail-page tag chips become anchor links to `/hikes?tag=<name>`.
HikesFilterBar grows a tags fieldset (sorted by frequency, with the
hash prefix the chips use) so the user can keep narrowing from there.

Multi-tag filtering is OR — a hike matching any selected tag stays
visible. AND would shrink the listing fast given how few tags most
hikes carry; OR matches what "show me more like this" feels like.

The overview page reads `tag` query params on mount and pre-fills the
filter — supports repeated params (`?tag=winter&tag=easy`).
This commit is contained in:
2026-05-19 10:13:26 +02:00
parent 3331536ddd
commit 2a8721fde0
4 changed files with 110 additions and 4 deletions
@@ -9,6 +9,7 @@
maxLossM: number;
difficulties: SvelteSet<Difficulty>;
regions: SvelteSet<string>;
tags: SvelteSet<string>;
};
interface Props {
@@ -37,6 +38,22 @@
return out.sort((a, b) => a.localeCompare(b));
});
// Tags sorted by usage frequency (most-used first), alphabetical for
// ties. Frequency ordering surfaces broadly-applicable filters like
// "winter" or "easy" at the head of the list, where they're most
// useful for narrowing the listing.
const tags = $derived.by(() => {
const counts = new Map<string, number>();
for (const h of hikes) {
for (const t of h.tags ?? []) {
counts.set(t, (counts.get(t) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([t]) => t);
});
function toggleDifficulty(d: Difficulty) {
if (filter.difficulties.has(d)) filter.difficulties.delete(d);
else filter.difficulties.add(d);
@@ -47,6 +64,11 @@
else filter.regions.add(r);
}
function toggleTag(t: string) {
if (filter.tags.has(t)) filter.tags.delete(t);
else filter.tags.add(t);
}
function resetFilters() {
filter.maxDistanceKm = maxDistance;
filter.maxDurationMin = maxDuration;
@@ -54,6 +76,7 @@
filter.maxLossM = maxLoss;
filter.difficulties.clear();
filter.regions.clear();
filter.tags.clear();
}
</script>
@@ -149,6 +172,24 @@
<button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button>
</div>
{#if tags.length > 0}
<fieldset class="tags-fieldset">
<legend>Schlagwörter</legend>
<div class="pills">
{#each tags as t (t)}
<button
type="button"
class="pill pill-tag"
class:active={filter.tags.has(t)}
onclick={() => toggleTag(t)}
>
<span class="pill-tag-hash" aria-hidden="true">#</span>{t}
</button>
{/each}
</div>
</fieldset>
{/if}
</aside>
<style>
@@ -254,4 +295,26 @@
.reset:hover {
background: var(--color-bg-elevated);
}
/* Tags fieldset spans the full filter-bar width — there can be many
* (every word an author uses), and forcing it into a column with the
* single-line region/difficulty groups makes them all unreadable. */
.tags-fieldset {
margin-top: 1rem;
}
.pill-tag {
display: inline-flex;
align-items: baseline;
gap: 0.2rem;
}
.pill-tag-hash {
opacity: 0.55;
font-weight: 600;
}
.pill-tag.active .pill-tag-hash {
opacity: 0.85;
}
</style>