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:
170
web/src/lib/util/trail_filter_util.ts
Normal file
170
web/src/lib/util/trail_filter_util.ts
Normal 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;
|
||||
}
|
||||
@@ -1,20 +1,50 @@
|
||||
<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 TrailFilterPanel from "$lib/components/trail/trail_filter_panel.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 type { Snapshot } from "@sveltejs/kit";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
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 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({
|
||||
page: page.url.searchParams.has("page")
|
||||
? parseInt(page.url.searchParams.get("page")!)
|
||||
@@ -27,24 +57,7 @@
|
||||
export const snapshot: Snapshot<TrailFilter> = {
|
||||
capture: () => filter,
|
||||
restore: (value) => {
|
||||
const difficultyMap: Record<string, 0 | 1 | 2> = {
|
||||
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;
|
||||
filter = sanitizeTrailFilter(value, page.data.filter);
|
||||
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() {
|
||||
loading = true;
|
||||
persistFilter();
|
||||
|
||||
await paginate(1, pagination.items);
|
||||
await paginate(1, pagination.items, false);
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function paginate(newPage: number, items: number) {
|
||||
async function paginate(newPage: number, items: number, scrollToTop: boolean = true) {
|
||||
pagination.page = newPage;
|
||||
|
||||
try {
|
||||
@@ -101,7 +128,7 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user