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",
|
"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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user