Files
wanderer/web/src/routes/trails/+page.svelte
slothful-vassal f6354dcd56 narrow-wide trail list view mode added (#666)
* narrow-wide trail list view mode added

* change icon, change pos, add i18n

* change icon

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
2026-04-01 19:07:27 +02:00

255 lines
7.8 KiB
Svelte

<script lang="ts">
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 } 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(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")!)
: 1,
totalPages: 1,
items: 25,
});
let trails: Trail[] = $state([]);
let trailsFullWidth = $state(false);
let widthPreferences: Record<string, boolean> = $state({});
let currentDisplayMode: string = $state("cards");
const widthPreferenceStorageKey = "trailWidthPreferences";
export const snapshot: Snapshot<TrailFilter> = {
capture: () => filter,
restore: (value) => {
filter = sanitizeTrailFilter(value, page.data.filter);
handleFilterUpdate();
},
};
onMount(() => {
if (window.innerWidth < 768) {
filterExpanded = false;
}
loadWidthPreferences();
});
function loadWidthPreferences() {
if (typeof localStorage === "undefined") {
return;
}
try {
const stored = localStorage.getItem(widthPreferenceStorageKey);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") {
widthPreferences = parsed;
}
}
} catch (err) {
console.warn("Failed to parse trail width preferences", err);
}
applyWidthPreference(currentDisplayMode);
}
function applyWidthPreference(mode: string) {
trailsFullWidth = widthPreferences[mode] ?? false;
}
function persistWidthPreferences() {
if (typeof localStorage === "undefined") {
return;
}
try {
localStorage.setItem(
widthPreferenceStorageKey,
JSON.stringify(widthPreferences),
);
} catch (err) {
console.warn("Failed to persist trail width preferences", err);
}
}
function updateWidthPreference(mode: string, value: boolean) {
widthPreferences = { ...widthPreferences, [mode]: value };
persistWidthPreferences();
}
function handleDisplayModeChange(mode: string) {
currentDisplayMode = mode;
applyWidthPreference(mode);
}
function toggleTrailWidth() {
trailsFullWidth = !trailsFullWidth;
updateWidthPreference(currentDisplayMode, trailsFullWidth);
}
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(resetPagination: boolean = true) {
loading = true;
persistFilter();
await paginate(resetPagination ? 1 : pagination.page, pagination.items);
loading = false;
}
async function paginate(
newPage: number,
items: number,
scrollToTop: boolean = true,
) {
pagination.page = newPage;
try {
await doPaginate(newPage, items);
} catch (err: any) {
let apiError: APIError = err;
if (apiError.status == 413) {
// content too large
let newItems = 10;
if (items == 12 || items == 24 || items == 48 || items == 96) {
// cards view
if (items > 96) {
newItems = 96;
} else if (items > 48) {
newItems = 48;
} else if (items > 24) {
newItems = 24;
} else {
newItems = 12;
}
} else {
if (items > 100) {
newItems = 100;
} else if (items > 50) {
newItems = 50;
} else if (items > 25) {
newItems = 25;
} else {
newItems = 10;
}
}
await doPaginate(newPage, newItems);
}
}
page.url.searchParams.set("page", newPage.toString());
goto(`?${page.url.searchParams.toString()}`, {
keepFocus: true,
noScroll: !scrollToTop,
});
}
async function doPaginate(newPage: number, items: number) {
const response = await trails_search_filter(filter, newPage, items);
if (items) {
pagination.items = items;
}
trails = response.items;
pagination.page = response.page;
pagination.totalPages = response.totalPages;
}
</script>
<svelte:head>
<title>{$_("trail", { values: { n: 2 } })} | wanderer</title>
</svelte:head>
<main
class={`grid grid-cols-1 md:grid-cols-[300px_1fr] items-start gap-8 mx-6 ${trailsFullWidth ? "md:mx-6 max-w-full" : "md:mx-auto max-w-7xl"}`}
>
<TrailFilterPanel
categories={page.data.categories}
bind:filter
{filterExpanded}
onupdate={() => handleFilterUpdate()}
></TrailFilterPanel>
<TrailList
bind:filter
{loading}
{trails}
{pagination}
onupdate={() => handleFilterUpdate(false)}
onpagination={paginate}
ondisplaychange={handleDisplayModeChange}
>
{#snippet trailWidthToggleSnippet()}
<button
type="button"
class="btn-icon"
onclick={toggleTrailWidth}
aria-pressed={trailsFullWidth}
aria-label={trailsFullWidth
? $_("collapse-trail-list")
: $_("expand-trail-list")}
title={trailsFullWidth
? $_("collapse-trail-list")
: $_("expand-trail-list")}
>
<i class="fa {trailsFullWidth ? 'fa-compress' : 'fa-expand'}"
></i>
</button>
{/snippet}
</TrailList>
</main>