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 {
maxSearchLength?: number;
timeBetweenUpdates?: number,
value?: string;
items?: SearchItem[];
placeholder?: string;
@@ -29,6 +30,7 @@
let {
maxSearchLength = 5,
timeBetweenUpdates = 500,
value = $bindable(""),
items = $bindable([]),
placeholder = "Search...",
@@ -57,7 +59,7 @@
}
typingTimer = setTimeout(() => {
update(value);
}, 500);
}, timeBetweenUpdates);
}
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 Dropdown, { type DropdownItem } from "../base/dropdown.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 TrailShareModal from "./trail_share_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
@@ -35,7 +35,7 @@
let { trails, mode, toggle, onDelete, onShare }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
@@ -436,12 +436,12 @@
bind:this={confirmModal}
onconfirm={deleteTrails}
></ConfirmModal>
<ListSelectModal
<ListSearchModal
{lists}
trails={getTrails()}
bind:this={listSelectModal}
onchange={(list) => handleListSelection(list)}
></ListSelectModal>
></ListSearchModal>
<TrailExportModal
bind:this={trailExportModal}
onexport={(settings) => exportTrails(settings)}