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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.75.2", "version": "1.75.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -9,6 +9,7 @@
maxLossM: number; maxLossM: number;
difficulties: SvelteSet<Difficulty>; difficulties: SvelteSet<Difficulty>;
regions: SvelteSet<string>; regions: SvelteSet<string>;
tags: SvelteSet<string>;
}; };
interface Props { interface Props {
@@ -37,6 +38,22 @@
return out.sort((a, b) => a.localeCompare(b)); 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) { function toggleDifficulty(d: Difficulty) {
if (filter.difficulties.has(d)) filter.difficulties.delete(d); if (filter.difficulties.has(d)) filter.difficulties.delete(d);
else filter.difficulties.add(d); else filter.difficulties.add(d);
@@ -47,6 +64,11 @@
else filter.regions.add(r); 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() { function resetFilters() {
filter.maxDistanceKm = maxDistance; filter.maxDistanceKm = maxDistance;
filter.maxDurationMin = maxDuration; filter.maxDurationMin = maxDuration;
@@ -54,6 +76,7 @@
filter.maxLossM = maxLoss; filter.maxLossM = maxLoss;
filter.difficulties.clear(); filter.difficulties.clear();
filter.regions.clear(); filter.regions.clear();
filter.tags.clear();
} }
</script> </script>
@@ -149,6 +172,24 @@
<button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button> <button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button>
</div> </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> </aside>
<style> <style>
@@ -254,4 +295,26 @@
.reset:hover { .reset:hover {
background: var(--color-bg-elevated); 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> </style>
+25 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import HikeCard from '$lib/components/hikes/HikeCard.svelte'; import HikeCard from '$lib/components/hikes/HikeCard.svelte';
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte'; import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte'; import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
@@ -54,7 +55,19 @@
maxGainM: Number.POSITIVE_INFINITY, maxGainM: Number.POSITIVE_INFINITY,
maxLossM: Number.POSITIVE_INFINITY, maxLossM: Number.POSITIVE_INFINITY,
difficulties: new SvelteSet<Difficulty>(), difficulties: new SvelteSet<Difficulty>(),
regions: new SvelteSet<string>() regions: new SvelteSet<string>(),
tags: new SvelteSet<string>()
});
// Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`)
// or any saved URL with `?tag=...` pre-selects those tags. Repeated
// params accumulate (`?tag=winter&tag=easy`). Only runs on the client —
// SSR has no searchParams to read here.
$effect(() => {
if (typeof window === 'undefined') return;
const params = page.url.searchParams.getAll('tag');
if (params.length === 0) return;
for (const t of params) if (t) filter.tags.add(t);
}); });
// One-shot per mount: set the slider ceilings to the actual data maxes. // One-shot per mount: set the slider ceilings to the actual data maxes.
@@ -82,6 +95,17 @@
if (h.elevationLossM > filter.maxLossM) continue; if (h.elevationLossM > filter.maxLossM) continue;
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue; if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue;
if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue; if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue;
// Multi-tag = OR (a hike matching ANY selected tag is shown). AND
// would shrink the listing to ~zero quickly given how few tags
// most hikes have; OR matches how detail-page chips feel like
// "show me more like this".
if (filter.tags.size > 0) {
let any = false;
for (const t of h.tags) {
if (filter.tags.has(t)) { any = true; break; }
}
if (!any) continue;
}
out.push(h); out.push(h);
} }
return out; return out;
+21 -2
View File
@@ -424,10 +424,14 @@
{#if hike.tags.length > 0} {#if hike.tags.length > 0}
<!-- Tag chips sit between the metric tiles (facts) and the <!-- Tag chips sit between the metric tiles (facts) and the
elevation profile (data viz) so they read as framing context — elevation profile (data viz) so they read as framing context —
"what kind of hike is this" — before the data takes over. --> "what kind of hike is this" — before the data takes over.
Each chip is an anchor link to the /hikes overview with that
tag pre-selected in the filter bar. -->
<section class="tags" aria-label="Schlagwörter"> <section class="tags" aria-label="Schlagwörter">
{#each hike.tags as tag (tag)} {#each hike.tags as tag (tag)}
<span class="tag-chip"><span class="tag-hash" aria-hidden="true">#</span>{tag}</span> <a class="tag-chip" href="/hikes?tag={encodeURIComponent(tag)}">
<span class="tag-hash" aria-hidden="true">#</span>{tag}
</a>
{/each} {/each}
</section> </section>
{/if} {/if}
@@ -768,6 +772,17 @@
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
letter-spacing: 0.005em; letter-spacing: 0.005em;
text-decoration: none;
transition:
color var(--transition-fast),
background-color var(--transition-fast),
scale var(--transition-fast);
}
.tag-chip:hover {
color: var(--color-primary);
background: var(--color-bg-elevated);
scale: 1.05;
} }
.tag-hash { .tag-hash {
@@ -775,6 +790,10 @@
font-weight: 600; font-weight: 600;
} }
.tag-chip:hover .tag-hash {
color: color-mix(in oklab, var(--color-primary) 60%, currentColor);
}
.elev-area { .elev-area {
padding: 0 1rem; padding: 0 1rem;
margin-top: 0.25rem; margin-top: 0.25rem;