fix list fetch timeout (#701)

* fix list fetch timeout

* list search modal added (replaces list select modal)

* query startsWith -> includes

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
slothful-vassal
2026-01-11 22:03:35 +01:00
committed by GitHub
parent 741cb51096
commit 9282f37c15
8 changed files with 266 additions and 107 deletions

View File

@@ -15,6 +15,7 @@
interface Props { interface Props {
maxSearchLength?: number; maxSearchLength?: number;
timeBetweenUpdates?: number,
value?: string; value?: string;
items?: SearchItem[]; items?: SearchItem[];
placeholder?: string; placeholder?: string;
@@ -29,6 +30,7 @@
let { let {
maxSearchLength = 5, maxSearchLength = 5,
timeBetweenUpdates = 500,
value = $bindable(""), value = $bindable(""),
items = $bindable([]), items = $bindable([]),
placeholder = "Search...", placeholder = "Search...",
@@ -57,7 +59,7 @@
} }
typingTimer = setTimeout(() => { typingTimer = setTimeout(() => {
update(value); update(value);
}, 500); }, timeBetweenUpdates);
} }
function update(q: string) { function update(q: string) {

View File

@@ -0,0 +1,243 @@
<script lang="ts">
import { type Snippet } from "svelte";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { trail } from "$lib/stores/trail_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import Modal from "../base/modal.svelte";
import Search, { type SearchItem } from "../base/search.svelte";
import { theme } from "$lib/stores/theme_store";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
interface Props {
lists?: List[];
trails?: Set<Trail> | undefined;
children?: Snippet<[any]>;
onchange?: (list: List) => void;
}
let { lists = [], trails, children, onchange }: Props = $props();
let modal: Modal;
let searchItems: SearchItem[] = $state([]);
let query = $state("");
let listsVersion = $state(0);
export function openModal() {
searchItems = [];
query = "";
modal.openModal();
}
function handleSelect(list: List) {
const containsAll = listContainsAllTrails(list);
onchange?.(list);
if (containsAll) {
removeTrailsFromList(list);
} else {
addTrailsToList(list);
}
listsVersion += 1;
updateLists(query);
}
function handleRemove(list: List) {
onchange?.(list);
removeTrailsFromList(list);
listsVersion += 1;
updateLists(query);
}
function listContainsAllTrails(list: List): boolean {
if (trails === undefined) {
return listContainsCurrentTrail(list) ?? false;
} else if (list.trails !== undefined) {
for (const lTrail of trails) {
if (!list.trails!.includes(lTrail.id!)) return false;
}
return true;
}
return false;
}
function listContainsCurrentTrail(list: List) {
return list.trails?.includes($trail.id!);
}
function getSelectedTrailIds(): string[] {
if (trails !== undefined) {
return Array.from(trails)
.map((lTrail) => lTrail.id)
.filter((id): id is string => Boolean(id));
}
return $trail?.id ? [$trail.id] : [];
}
function addTrailsToList(list: List) {
const selectedTrailIds = getSelectedTrailIds();
if (!selectedTrailIds.length) {
return;
}
list.trails ??= [];
for (const id of selectedTrailIds) {
if (!list.trails.includes(id)) {
list.trails = [...list.trails, id];
}
}
}
function removeTrailsFromList(list: List) {
const selectedTrailIds = getSelectedTrailIds();
if (!selectedTrailIds.length || !list.trails?.length) {
return;
}
list.trails = list.trails.filter(
(id) => !selectedTrailIds.includes(id),
);
}
const listsContainingTrails = $derived.by(() =>
lists.filter((list) => {
listsVersion;
return listContainsAllTrails(list);
}),
);
const showNoListHint = $derived.by(() => {
if (query.trim().length) {
return false;
}
if (trails !== undefined) {
return trails.size > 0 && listsContainingTrails.length === 0;
}
return Boolean($trail?.id) && listsContainingTrails.length === 0;
});
function updateLists(q: string) {
query = q;
const normalizedQuery = q.trim().toLowerCase();
if (!normalizedQuery.length) {
searchItems = [];
return;
}
searchItems = lists
.filter((list) => {
const name = list.name?.toLowerCase() ?? "";
return (
name.includes(normalizedQuery) &&
!listContainsAllTrails(list)
);
})
.map((list) => ({
text: list.name,
description: list.description,
value: list,
icon: list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
}));
}
const children_render = $derived(children);
</script>
<Modal
id="list-search-modal"
title={$_("select-list")}
size="md:min-w-sm"
bind:this={modal}
>
{#snippet children({ openModal })}
{@render children_render?.({ openModal })}
{/snippet}
{#snippet content()}
<div class="space-y-4 min-h-40">
<Search
onupdate={(q) => updateLists(q)}
timeBetweenUpdates={0}
onclick={(item) => handleSelect(item.value)}
placeholder={$_("search-list")}
items={searchItems}
bind:value={query}
>
{#snippet prepend({ item })}
<img
class="rounded-full w-8 aspect-square mr-2"
src={item.icon}
alt="avatar"
/>
{/snippet}
</Search>
{#if query.trim().length && searchItems.length === 0}
<p class="text-sm text-gray-500 text-center">
{$_("no-results")}
</p>
{/if}
{#if listsContainingTrails.length}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-gray-500">
{$_("linked-lists")}
</h4>
{#each listsContainingTrails as list}
<div
class="w-full flex items-center gap-4 p-4 rounded-xl bg-menu-item-background"
>
<img
class="w-12 aspect-square rounded-full"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<h5 class="text-md font-semibold text-left">
{list.name}
</h5>
<button
class="ml-auto btn-icon text-red-500"
onclick={() => handleRemove(list)}
type="button"
aria-label={$_("unlink")}
>
<i class="fa fa-trash"></i>
</button>
</div>
{/each}
</div>
{:else if showNoListHint}
<div class="min-h-20 flex items-center justify-center">
<p class="text-sm text-gray-500 text-center">
{$_("trail-not-in-list")}
</p>
</div>
{/if}
</div>
{/snippet}
{#snippet footer({ closeModal })}
<div class="flex justify-end">
<button class="btn-primary" onclick={closeModal}>
{$_("close")}
</button>
</div>
{/snippet}
</Modal>

View File

@@ -1,93 +0,0 @@
<script lang="ts">
import { type Snippet } from "svelte";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { trail } from "$lib/stores/trail_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import Modal from "../base/modal.svelte";
import { theme } from "$lib/stores/theme_store";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
interface Props {
lists: List[];
trails?: Set<Trail> | undefined;
children?: Snippet<[any]>;
onchange?: (list: List) => void
}
let { lists, trails, children, onchange }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
function handleSelect(list: List) {
onchange?.(list);
modal.closeModal!();
}
function listContainsAllTrails(list: List) : boolean {
if (trails === undefined) {
return listContainsCurrentTrail(list) ?? false;
} else if (list.trails !== undefined) {
for (const lTrail of trails) {
if (!list.trails!.includes(lTrail.id!)) return false;
}
return true;
}
return false;
}
function listContainsCurrentTrail(list: List) {
return list.trails?.includes($trail.id!);
}
const children_render = $derived(children);
</script>
<Modal
id="list-modal"
title={$_("select-list")}
size="md:min-w-sm"
bind:this={modal}
>
{#snippet children({ openModal })}
{@render children_render?.({ openModal })}
{/snippet}
{#snippet content()}
<ul>
{#each lists as list}
<li
class="flex gap-4 items-center p-4 hover:bg-menu-item-background-hover rounded-xl transition-colors cursor-pointer"
onclick={() => handleSelect(list)}
role="presentation"
>
<img
class="w-12 aspect-square rounded-full"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<h5 class="text-md font-semibold">{list.name}</h5>
<i
class="fa fa-{listContainsAllTrails(list)
? 'minus'
: 'plus'} rounded-full border border-input-border p-2"
></i>
</li>
{/each}
</ul>
{/snippet}
</Modal>

View File

@@ -17,7 +17,7 @@
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte"; import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ConfirmModal from "../confirm_modal.svelte"; import ConfirmModal from "../confirm_modal.svelte";
import ListSelectModal from "../list/list_select_modal.svelte"; import ListSearchModal from "../list/list_search_modal.svelte";
import TrailExportModal from "./trail_export_modal.svelte"; import TrailExportModal from "./trail_export_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte"; import TrailShareModal from "./trail_share_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util"; import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
@@ -35,7 +35,7 @@
let { trails, mode, toggle, onDelete, onShare }: Props = $props(); let { trails, mode, toggle, onDelete, onShare }: Props = $props();
let confirmModal: ConfirmModal; let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal; let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal; let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal; let trailShareModal: TrailShareModal;
@@ -436,12 +436,12 @@
bind:this={confirmModal} bind:this={confirmModal}
onconfirm={deleteTrails} onconfirm={deleteTrails}
></ConfirmModal> ></ConfirmModal>
<ListSelectModal <ListSearchModal
{lists} {lists}
trails={getTrails()} trails={getTrails()}
bind:this={listSelectModal} bind:this={listSelectModal}
onchange={(list) => handleListSelection(list)} onchange={(list) => handleListSelection(list)}
></ListSelectModal> ></ListSearchModal>
<TrailExportModal <TrailExportModal
bind:this={trailExportModal} bind:this={trailExportModal}
onexport={(settings) => exportTrails(settings)} onexport={(settings) => exportTrails(settings)}

View File

@@ -11,7 +11,7 @@
"activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}", "activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}",
"add-bio": "Bio hinzufügen", "add-bio": "Bio hinzufügen",
"add-entry": "Eintrag hinzufügen", "add-entry": "Eintrag hinzufügen",
"add-to-list": "Zu Liste hinzufügen", "add-to-list": "Listen verwalten",
"add-waypoint": "Wegpunkt hinzufügen", "add-waypoint": "Wegpunkt hinzufügen",
"added-trail-to": "Route hinzugefügt zu", "added-trail-to": "Route hinzugefügt zu",
"added-trails-to": "Routen hinzugefügt zu", "added-trails-to": "Routen hinzugefügt zu",
@@ -220,6 +220,7 @@
"likes": "Likes", "likes": "Likes",
"limited": "Begrenzt", "limited": "Begrenzt",
"link-copied": "Link kopiert!", "link-copied": "Link kopiert!",
"linked-lists": "Verknüpfte Listen",
"list": "{n, plural, =1 {Liste} other {Listen}}", "list": "{n, plural, =1 {Liste} other {Listen}}",
"list-not-shared": "Mit niemandem geteilt", "list-not-shared": "Mit niemandem geteilt",
"list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.", "list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.",
@@ -403,7 +404,9 @@
"tourism": "Tourismus", "tourism": "Tourismus",
"trail": "{n, plural, =1 {Route} other {Routen}}", "trail": "{n, plural, =1 {Route} other {Routen}}",
"trail-not-shared": "Mit niemandem geteilt", "trail-not-shared": "Mit niemandem geteilt",
"trail-not-in-list": "Trail gehört zu keiner Liste.",
"trail-saved-successfully": "Route gespeichert", "trail-saved-successfully": "Route gespeichert",
"search-list": "Liste suchen",
"trails-for-you": "Routen für dich", "trails-for-you": "Routen für dich",
"tram-stop": "Tram Haltestelle", "tram-stop": "Tram Haltestelle",
"unchanged": "unverändert", "unchanged": "unverändert",

View File

@@ -11,7 +11,7 @@
"activity": "{n, plural, =1 {Activity} other {Activities}}", "activity": "{n, plural, =1 {Activity} other {Activities}}",
"add-bio": "Add Bio", "add-bio": "Add Bio",
"add-entry": "Add Entry", "add-entry": "Add Entry",
"add-to-list": "Add to list", "add-to-list": "Manage lists",
"add-waypoint": "Add Waypoint", "add-waypoint": "Add Waypoint",
"added-trail-to": "Added trail to", "added-trail-to": "Added trail to",
"added-trails-to": "Added trails to", "added-trails-to": "Added trails to",
@@ -220,6 +220,7 @@
"likes": "Likes", "likes": "Likes",
"limited": "Limited", "limited": "Limited",
"link-copied": "Link copied!", "link-copied": "Link copied!",
"linked-lists": "Linked lists",
"list": "{n, plural, =1 {List} other {Lists}}", "list": "{n, plural, =1 {List} other {Lists}}",
"list-not-shared": "Not shared with anyone", "list-not-shared": "Not shared with anyone",
"list-public-warning": "All trails in this list will become public.", "list-public-warning": "All trails in this list will become public.",
@@ -403,7 +404,9 @@
"tourism": "Tourism", "tourism": "Tourism",
"trail": "{n, plural, =1 {Trail} other {Trails}}", "trail": "{n, plural, =1 {Trail} other {Trails}}",
"trail-not-shared": "Not shared with anyone", "trail-not-shared": "Not shared with anyone",
"trail-not-in-list": "Trail is not in any list",
"trail-saved-successfully": "Trail saved successfully", "trail-saved-successfully": "Trail saved successfully",
"search-list": "Search list",
"trails-for-you": "Trails for you", "trails-for-you": "Trails for you",
"tram-stop": "Tram stop", "tram-stop": "Tram stop",
"unchanged": "unchanged", "unchanged": "unchanged",

View File

@@ -47,11 +47,12 @@ export enum Collection {
export async function list<T>(event: RequestEvent, collection: Collection) { export async function list<T>(event: RequestEvent, collection: Collection) {
const searchParams = Object.fromEntries(event.url.searchParams); const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = RecordListOptionsSchema.parse(searchParams); const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
const { perPage, page, ...opts } = safeSearchParams;
let r: ListResult<T>; let r: ListResult<T>;
if ((safeSearchParams.perPage ?? 0) < 0) { if ((safeSearchParams.perPage ?? 0) < 0) {
const activities: T[] = await event.locals.pb.collection(Collection[collection]) const activities: T[] = await event.locals.pb.collection(Collection[collection])
.getFullList<T>(safeSearchParams) .getFullList<T>({...opts})
r = { r = {
items: activities, items: activities,
perPage: -1, perPage: -1,
@@ -61,7 +62,7 @@ export async function list<T>(event: RequestEvent, collection: Collection) {
} }
} else { } else {
r = await event.locals.pb.collection(Collection[collection]) r = await event.locals.pb.collection(Collection[collection])
.getList<T>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams }) .getList<T>(page, perPage, opts)
} }
return r return r
} }

View File

@@ -5,7 +5,7 @@
import Select from "$lib/components/base/select.svelte"; import Select from "$lib/components/base/select.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte"; import Toggle from "$lib/components/base/toggle.svelte";
import ListSelectModal from "$lib/components/list/list_select_modal.svelte"; import ListSearchModal from "$lib/components/list/list_search_modal.svelte";
import SummitLogCard from "$lib/components/summit_log/summit_log_card.svelte"; import SummitLogCard from "$lib/components/summit_log/summit_log_card.svelte";
import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte"; import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
@@ -111,7 +111,7 @@
let waypointModal: WaypointModal; let waypointModal: WaypointModal;
let summitLogModal: SummitLogModal; let summitLogModal: SummitLogModal;
let listSelectModal: ListSelectModal; let listSelectModal: ListSearchModal;
let loading = $state(false); let loading = $state(false);
@@ -1516,11 +1516,11 @@
<WaypointModal bind:this={waypointModal} onsave={saveWaypoint}></WaypointModal> <WaypointModal bind:this={waypointModal} onsave={saveWaypoint}></WaypointModal>
<SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)} <SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal> ></SummitLogModal>
<ListSelectModal <ListSearchModal
lists={lists.items} lists={lists.items}
bind:this={listSelectModal} bind:this={listSelectModal}
onchange={(e) => handleListSelection(e)} onchange={(e) => handleListSelection(e)}
></ListSelectModal> ></ListSearchModal>
<style> <style>
#trail-map { #trail-map {