|
1 | 1 | <script lang="ts"> |
2 | | - import type { IEntry } from '$lib/dotnet-types'; |
3 | | - import type { IQueryOptions } from '$lib/dotnet-types/generated-types/MiniLcm/IQueryOptions'; |
4 | | - import { SortField } from '$lib/dotnet-types/generated-types/MiniLcm/SortField'; |
5 | | - import { Debounced, resource } from 'runed'; |
6 | | - import { useMiniLcmApi } from '$lib/services/service-provider'; |
| 2 | + import type {IEntry} from '$lib/dotnet-types'; |
| 3 | + import type {IQueryOptions} from '$lib/dotnet-types/generated-types/MiniLcm/IQueryOptions'; |
| 4 | + import {SortField} from '$lib/dotnet-types/generated-types/MiniLcm/SortField'; |
| 5 | + import {resource, useDebounce} from 'runed'; |
| 6 | + import {useMiniLcmApi} from '$lib/services/service-provider'; |
7 | 7 | import EntryRow from './EntryRow.svelte'; |
8 | 8 | import Button from '$lib/components/ui/button/button.svelte'; |
9 | | - import { cn } from '$lib/utils'; |
10 | | - import { t } from 'svelte-i18n-lingui'; |
11 | | - import {ScrollArea} from '$lib/components/ui/scroll-area'; |
| 9 | + import {cn} from '$lib/utils'; |
| 10 | + import {t} from 'svelte-i18n-lingui'; |
12 | 11 | import DevContent from '$lib/layout/DevContent.svelte'; |
13 | 12 | import NewEntryButton from '../NewEntryButton.svelte'; |
14 | 13 | import {useDialogsService} from '$lib/services/dialogs-service'; |
15 | 14 | import {useProjectEventBus} from '$lib/services/event-bus'; |
16 | 15 | import EntryMenu from './EntryMenu.svelte'; |
17 | 16 | import FabContainer from '$lib/components/fab/fab-container.svelte'; |
| 17 | + import {VList, type VListHandle} from 'virtua/svelte'; |
18 | 18 |
|
19 | 19 | const { |
20 | 20 | search = '', |
|
38 | 38 | projectEventBus.onEntryDeleted(entryId => { |
39 | 39 | if (selectedEntryId === entryId) onSelectEntry(undefined); |
40 | 40 | if (entriesResource.loading || !entries.some(e => e.id === entryId)) return; |
41 | | - void entriesResource.refetch(); |
| 41 | + const currentIndex = entriesResource.current?.findIndex(e => e.id === entryId) ?? -1; |
| 42 | + if (currentIndex >= 0) { |
| 43 | + entriesResource.current!.splice(currentIndex, 1); |
| 44 | + } |
42 | 45 | }); |
43 | 46 | projectEventBus.onEntryUpdated(_entry => { |
44 | 47 | if (entriesResource.loading) return; |
45 | | - void entriesResource.refetch(); |
| 48 | + const currentIndex = entriesResource.current?.findIndex(e => e.id === _entry.id) ?? -1; |
| 49 | + if (currentIndex >= 0) { |
| 50 | + entriesResource.current![currentIndex] = _entry; |
| 51 | + } else { |
| 52 | + void silentlyRefreshEntries(); |
| 53 | + } |
46 | 54 | }); |
47 | 55 |
|
| 56 | + async function silentlyRefreshEntries() { |
| 57 | + const updatedEntries = await fetchCurrentEntries(true); |
| 58 | + entriesResource.mutate(updatedEntries); |
| 59 | + } |
48 | 60 |
|
49 | | - const entriesResource = resource( |
50 | | - () => ({ search, sortDirection, gridifyFilter }), |
51 | | - async ({ search, sortDirection, gridifyFilter }) => { |
| 61 | + let loading = $state(false); |
| 62 | + const fetchCurrentEntries = useDebounce(async (silent = false) => { |
| 63 | + if (!silent) loading = true; |
| 64 | + try { |
52 | 65 | const queryOptions: IQueryOptions = { |
53 | | - count: 100, |
| 66 | + count: 10_000, |
54 | 67 | offset: 0, |
55 | 68 | filter: { |
56 | 69 | gridifyFilter: gridifyFilter ? gridifyFilter : undefined, |
|
63 | 76 | }; |
64 | 77 |
|
65 | 78 | if (search) { |
66 | | - return miniLcmApi.searchEntries(search, queryOptions); |
| 79 | + return await miniLcmApi.searchEntries(search, queryOptions); |
67 | 80 | } |
68 | | - return miniLcmApi.getEntries(queryOptions); |
69 | | - }, |
70 | | - { |
71 | | - debounce: 300, |
| 81 | + return await miniLcmApi.getEntries(queryOptions); |
| 82 | + } finally { |
| 83 | + loading = false; |
72 | 84 | } |
73 | | - ); |
| 85 | + }, 300); |
| 86 | +
|
| 87 | + const entriesResource = resource( |
| 88 | + () => ({ search, sortDirection, gridifyFilter }), |
| 89 | + async () => await fetchCurrentEntries()); |
74 | 90 | const entries = $derived(entriesResource.current ?? []); |
75 | | - const loading = new Debounced(() => entriesResource.loading, 50); |
76 | 91 |
|
77 | 92 | // Generate a random number of skeleton rows between 3 and 7 |
78 | 93 | const skeletonRowCount = Math.floor(Math.random() * 5) + 3; |
|
82 | 97 | if (!entry) return; |
83 | 98 | onSelectEntry(entry); |
84 | 99 | } |
| 100 | +
|
| 101 | + let vList = $state<VListHandle>(); |
| 102 | + $effect(() => { |
| 103 | + if (!vList || !selectedEntryId) return; |
| 104 | + const indexOfSelected = entries.findIndex(e => e.id === selectedEntryId); |
| 105 | + if (indexOfSelected === -1) return; |
| 106 | + if (indexOfSelected > vList.findEndIndex() || indexOfSelected < vList.findStartIndex()) |
| 107 | + { |
| 108 | + //using smooth scroll caused lag, maybe only do it if scrolling a short distance? |
| 109 | + vList.scrollToIndex(indexOfSelected, {align: 'center'}); |
| 110 | + } |
| 111 | + }); |
| 112 | +
|
85 | 113 | </script> |
86 | 114 |
|
87 | 115 | <FabContainer> |
88 | 116 | <DevContent> |
89 | 117 | <Button |
90 | | - icon={loading.current ? 'i-mdi-loading' : 'i-mdi-refresh'} |
| 118 | + icon={loading ? 'i-mdi-loading' : 'i-mdi-refresh'} |
91 | 119 | variant="outline" |
92 | | - iconProps={{ class: cn(loading.current && 'animate-spin') }} |
| 120 | + iconProps={{ class: cn(loading && 'animate-spin') }} |
93 | 121 | size="icon" |
94 | | - class="mb-4" |
95 | 122 | onclick={() => entriesResource.refetch()} |
96 | 123 | /> |
97 | 124 | </DevContent> |
98 | 125 | <NewEntryButton onclick={handleNewEntry} shortForm /> |
99 | 126 | </FabContainer> |
100 | 127 |
|
101 | | -<ScrollArea class="md:pr-3 flex-1" role="table"> |
| 128 | +<div class="flex-1 h-full" role="table"> |
102 | 129 | {#if entriesResource.error} |
103 | 130 | <div class="flex items-center justify-center h-full text-muted-foreground"> |
104 | 131 | <p>{$t`Failed to load entries`}</p> |
105 | 132 | <p>{entriesResource.error.message}</p> |
106 | 133 | </div> |
107 | 134 | {:else} |
108 | | - <div class="space-y-2 p-0.5 pb-12"> |
109 | | - {#if loading.current} |
110 | | - <!-- Show skeleton rows while loading --> |
111 | | - {#each { length: skeletonRowCount }, _index} |
112 | | - <EntryRow skeleton={true} /> |
113 | | - {/each} |
| 135 | + <div class="h-full"> |
| 136 | + {#if loading} |
| 137 | + <div class="md:pr-3 p-0.5"> |
| 138 | + <!-- Show skeleton rows while loading --> |
| 139 | + {#each { length: skeletonRowCount }, _index} |
| 140 | + <EntryRow class="mb-2" skeleton={true} /> |
| 141 | + {/each} |
| 142 | + </div> |
114 | 143 | {:else} |
115 | | - {#each entries as entry} |
116 | | - <EntryMenu {entry} contextMenu> |
| 144 | + <VList bind:this={vList} data={entries ?? []} class="h-full p-0.5 md:pr-3 after:h-12 after:block" getKey={d => d.id} overscan={10}> |
| 145 | + {#snippet children(entry)} |
| 146 | + <EntryMenu {entry} contextMenu> |
117 | 147 | <EntryRow {entry} |
118 | | - selected={selectedEntryId === entry.id} |
119 | | - onclick={() => onSelectEntry(entry)} |
120 | | - {previewDictionary} /> |
121 | | - </EntryMenu> |
122 | | - {:else} |
| 148 | + class="mb-2" |
| 149 | + selected={selectedEntryId === entry.id} |
| 150 | + onclick={() => onSelectEntry(entry)} |
| 151 | + {previewDictionary}/> |
| 152 | + </EntryMenu> |
| 153 | + {/snippet} |
| 154 | + </VList> |
| 155 | + {#if entries.length === 0} |
123 | 156 | <div class="flex items-center justify-center h-full text-muted-foreground"> |
124 | 157 | <p>{$t`No entries found`}</p> |
125 | 158 | </div> |
126 | | - {/each} |
| 159 | + {/if} |
127 | 160 | {/if} |
128 | 161 | </div> |
129 | 162 | {/if} |
130 | | -</ScrollArea> |
| 163 | +</div> |
0 commit comments