prevent losing trail filter settings (#814)

* prevent losing trail filter settings

* prevent page from jumping to top on filter update

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
slothful-vassal
2026-02-20 10:27:07 +01:00
committed by GitHub
parent ff10084ba0
commit 088e09dc53
2 changed files with 221 additions and 24 deletions

View File

@@ -0,0 +1,170 @@
import type { TrailFilter } from "$lib/models/trail";
const TRAIL_SORT_OPTIONS = new Set([
"name",
"distance",
"duration",
"difficulty",
"elevation_gain",
"elevation_loss",
"like_count",
"created",
"date",
]);
function limitToRange(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function getNumber(value: unknown, fallback: number): number;
function getNumber(value: unknown, fallback?: number): number | undefined;
function getNumber(value: unknown, fallback?: number): number | undefined {
return typeof value === "number" ? value : fallback;
}
function getNumberInRange(
value: unknown,
fallback: number,
min: number,
max: number,
): number {
return limitToRange(getNumber(value, fallback), min, max);
}
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object"
? (value as Record<string, unknown>)
: {};
}
function getString(value: unknown, fallback: string): string;
function getString(value: unknown, fallback?: string): string | undefined;
function getString(value: unknown, fallback?: string): string | undefined {
return typeof value === "string" ? value : fallback;
}
function getStringArray(value: unknown, fallback: string[]): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: fallback;
}
function toDifficulty(value: unknown): 0 | 1 | 2 | undefined {
if (value === 0 || value === 1 || value === 2) {
return value;
}
if (value === "easy") {
return 0;
}
if (value === "moderate") {
return 1;
}
if (value === "difficult") {
return 2;
}
return undefined;
}
function parseDifficulty(
value: unknown,
fallback: (0 | 1 | 2)[],
): (0 | 1 | 2)[] {
if (!Array.isArray(value)) {
return fallback;
}
const parsed = value
.map(toDifficulty)
.filter((d): d is 0 | 1 | 2 => d !== undefined);
return parsed.length ? parsed : fallback;
}
function getBoolean(value: unknown, fallback?: boolean): boolean | undefined {
return typeof value === "boolean" ? value : fallback;
}
export function sanitizeTrailFilter(
candidate: unknown,
defaultFilter: TrailFilter,
): TrailFilter {
const source = asRecord(candidate);
const near = asRecord(source.near);
const restored: TrailFilter = {
...defaultFilter,
q: getString(source.q, defaultFilter.q),
category: getStringArray(source.category, defaultFilter.category),
tags: getStringArray(source.tags, defaultFilter.tags),
difficulty: parseDifficulty(source.difficulty, defaultFilter.difficulty),
author: getString(source.author, defaultFilter.author),
public: getBoolean(source.public, defaultFilter.public),
shared: getBoolean(source.shared, defaultFilter.shared),
private: getBoolean(source.private, defaultFilter.private),
near: {
lat: getNumber(near.lat, defaultFilter.near.lat),
lon: getNumber(near.lon, defaultFilter.near.lon),
radius: Math.max(1, getNumber(near.radius, defaultFilter.near.radius)),
},
distanceLimit: defaultFilter.distanceLimit,
elevationGainLimit: defaultFilter.elevationGainLimit,
elevationLossLimit: defaultFilter.elevationLossLimit,
distanceMin: getNumberInRange(
source.distanceMin,
defaultFilter.distanceMin,
0,
defaultFilter.distanceLimit,
),
distanceMax: getNumberInRange(
source.distanceMax,
defaultFilter.distanceMax,
0,
defaultFilter.distanceLimit,
),
elevationGainMin: getNumberInRange(
source.elevationGainMin,
defaultFilter.elevationGainMin,
0,
defaultFilter.elevationGainLimit,
),
elevationGainMax: getNumberInRange(
source.elevationGainMax,
defaultFilter.elevationGainMax,
0,
defaultFilter.elevationGainLimit,
),
elevationLossMin: getNumberInRange(
source.elevationLossMin,
defaultFilter.elevationLossMin,
0,
defaultFilter.elevationLossLimit,
),
elevationLossMax: getNumberInRange(
source.elevationLossMax,
defaultFilter.elevationLossMax,
0,
defaultFilter.elevationLossLimit,
),
startDate: getString(source.startDate, defaultFilter.startDate),
endDate: getString(source.endDate, defaultFilter.endDate),
completed: getBoolean(source.completed, defaultFilter.completed),
liked: getBoolean(source.liked, defaultFilter.liked),
sort:
typeof source.sort === "string" && TRAIL_SORT_OPTIONS.has(source.sort)
? (source.sort as TrailFilter["sort"])
: defaultFilter.sort,
sortOrder: source.sortOrder === "-" ? "-" : "+",
};
if (restored.distanceMin > restored.distanceMax) {
restored.distanceMin = restored.distanceMax;
}
if (restored.elevationGainMin > restored.elevationGainMax) {
restored.elevationGainMin = restored.elevationGainMax;
}
if (restored.elevationLossMin > restored.elevationLossMax) {
restored.elevationLossMin = restored.elevationLossMax;
}
return restored;
}

View File

@@ -1,20 +1,50 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { browser } from "$app/environment";
import { beforeNavigate, goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import TrailFilterPanel from "$lib/components/trail/trail_filter_panel.svelte"; import TrailFilterPanel from "$lib/components/trail/trail_filter_panel.svelte";
import TrailList from "$lib/components/trail/trail_list.svelte"; import TrailList from "$lib/components/trail/trail_list.svelte";
import type { Trail, TrailFilter, TrailSearchResult } from "$lib/models/trail"; import type { Trail, TrailFilter } from "$lib/models/trail";
import { trails_search_filter } from "$lib/stores/trail_store"; import { trails_search_filter } from "$lib/stores/trail_store";
import type { Snapshot } from "@sveltejs/kit"; import type { Snapshot } from "@sveltejs/kit";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { APIError } from "$lib/util/api_util"; import { APIError } from "$lib/util/api_util";
import { sanitizeTrailFilter } from "$lib/util/trail_filter_util";
const TRAIL_LIST_FILTER_STORAGE_KEY = "trailListFilter";
function restoreStoredFilter(defaultFilter: TrailFilter): TrailFilter {
if (!browser) {
return defaultFilter;
}
const stored = localStorage.getItem(TRAIL_LIST_FILTER_STORAGE_KEY);
if (!stored) {
return defaultFilter;
}
try {
const parsed = JSON.parse(stored) as Record<string, unknown>;
return sanitizeTrailFilter(parsed, defaultFilter);
} catch {
localStorage.removeItem(TRAIL_LIST_FILTER_STORAGE_KEY);
return defaultFilter;
}
}
function persistFilter() {
if (!browser) {
return;
}
localStorage.setItem(TRAIL_LIST_FILTER_STORAGE_KEY, JSON.stringify(filter));
}
let filterExpanded: boolean = $state(true); let filterExpanded: boolean = $state(true);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let filter: TrailFilter = $state(page.data.filter); let filter: TrailFilter = $state(restoreStoredFilter(page.data.filter));
const pagination: { page: number; totalPages: number, items: number } = $state({ const pagination: { page: number; totalPages: number, items: number } = $state({
page: page.url.searchParams.has("page") page: page.url.searchParams.has("page")
? parseInt(page.url.searchParams.get("page")!) ? parseInt(page.url.searchParams.get("page")!)
@@ -27,24 +57,7 @@
export const snapshot: Snapshot<TrailFilter> = { export const snapshot: Snapshot<TrailFilter> = {
capture: () => filter, capture: () => filter,
restore: (value) => { restore: (value) => {
const difficultyMap: Record<string, 0 | 1 | 2> = { filter = sanitizeTrailFilter(value, page.data.filter);
easy: 0,
moderate: 1,
difficult: 2,
};
// defensive copy
const migrated = { ...value };
if (Array.isArray(migrated.difficulty)) {
migrated.difficulty = migrated.difficulty.map((d: any) => {
if (typeof d === "string" && d in difficultyMap) {
return difficultyMap[d];
}
return d;
});
}
filter = migrated;
handleFilterUpdate(); handleFilterUpdate();
}, },
}; };
@@ -55,15 +68,29 @@
} }
}); });
beforeNavigate(({ to }) => {
if (!browser || !to?.url) {
return;
}
// Keep filter for in-page navigation (e.g. pagination on /trails).
if (to.url.pathname.startsWith("/trails")) {
return;
}
localStorage.removeItem(TRAIL_LIST_FILTER_STORAGE_KEY);
});
async function handleFilterUpdate() { async function handleFilterUpdate() {
loading = true; loading = true;
persistFilter();
await paginate(1, pagination.items); await paginate(1, pagination.items, false);
loading = false; loading = false;
} }
async function paginate(newPage: number, items: number) { async function paginate(newPage: number, items: number, scrollToTop: boolean = true) {
pagination.page = newPage; pagination.page = newPage;
try { try {
@@ -101,7 +128,7 @@
} }
page.url.searchParams.set("page", newPage.toString()); page.url.searchParams.set("page", newPage.toString());
goto(`?${page.url.searchParams.toString()}`, { keepFocus: true }); goto(`?${page.url.searchParams.toString()}`, { keepFocus: true, noScroll: !scrollToTop });
} }
async function doPaginate(newPage: number, items: number) { async function doPaginate(newPage: number, items: number) {