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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.75.2",
|
||||
"version": "1.75.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/state';
|
||||
import HikeCard from '$lib/components/hikes/HikeCard.svelte';
|
||||
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
|
||||
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
|
||||
@@ -54,7 +55,19 @@
|
||||
maxGainM: Number.POSITIVE_INFINITY,
|
||||
maxLossM: Number.POSITIVE_INFINITY,
|
||||
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.
|
||||
@@ -82,6 +95,17 @@
|
||||
if (h.elevationLossM > filter.maxLossM) 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;
|
||||
// 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);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -424,10 +424,14 @@
|
||||
{#if hike.tags.length > 0}
|
||||
<!-- Tag chips sit between the metric tiles (facts) and the
|
||||
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">
|
||||
{#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}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -768,6 +772,17 @@
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-pill);
|
||||
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 {
|
||||
@@ -775,6 +790,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-chip:hover .tag-hash {
|
||||
color: color-mix(in oklab, var(--color-primary) 60%, currentColor);
|
||||
}
|
||||
|
||||
.elev-area {
|
||||
padding: 0 1rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
Reference in New Issue
Block a user