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">
|
<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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user