|
3 | 3 | import {Debounced, watch} from 'runed'; |
4 | 4 | import EntryRow from './EntryRow.svelte'; |
5 | 5 | import Button from '$lib/components/ui/button/button.svelte'; |
| 6 | + import ListItem from '$lib/components/ListItem.svelte'; |
6 | 7 | import {cn} from '$lib/utils'; |
7 | 8 | import {t} from 'svelte-i18n-lingui'; |
8 | 9 | import DevContent from '$lib/layout/DevContent.svelte'; |
|
20 | 21 | import {EntryLoaderService} from '$lib/services/entry-loader-service.svelte'; |
21 | 22 | import {onDestroy, untrack} from 'svelte'; |
22 | 23 | import {useViewService} from '$lib/views/view-service.svelte'; |
23 | | - import { pt } from '$lib/views/view-text'; |
| 24 | + import {pt} from '$lib/views/view-text'; |
24 | 25 |
|
25 | 26 | let { |
26 | 27 | search = '', |
|
61 | 62 | }; |
62 | 63 |
|
63 | 64 | let entryLoader = $derived(!miniLcmApi ? undefined : untrack(() => new EntryLoaderService(miniLcmApi, deps))); |
| 65 | + const displayedEntryCount = $derived(entryLoader?.totalCount ?? 0); |
64 | 66 |
|
65 | 67 | // Destroy the previous entryLoader when a new one is created |
66 | 68 | watch( |
|
102 | 104 | // Generate a random number of skeleton rows |
103 | 105 | const skeletonRowCount = Math.ceil(Math.random() * 3) + 3; |
104 | 106 |
|
105 | | - // Generate index array for virtual list. |
106 | | - // We use a small number of skeletons if the total count is not yet known |
107 | | - // to avoid a "white phase" between initial load and list initialization. |
108 | | - const indexArray = $derived( |
109 | | - entryLoader?.totalCount !== undefined |
110 | | - ? Array.from({ length: entryLoader.totalCount + 1 }, (_, i) => i) |
111 | | - : Array.from({ length: skeletonRowCount }, (_, i) => i) |
112 | | - ); |
| 107 | + const canCreateFromSearch = $derived(search?.trim() && !disableNewEntry); |
| 108 | + const showTerminalCreateRow = $derived(canCreateFromSearch && displayedEntryCount > 0); |
| 109 | +
|
| 110 | + type ListRow = {key: string, index: number, create?: boolean}; |
| 111 | + const rows: ListRow[] = $derived.by(() => { |
| 112 | + if (entryLoader?.totalCount === undefined) { |
| 113 | + return Array.from({length: skeletonRowCount}, (_, i) => ({key: `skeleton-${i}`, index: i})); |
| 114 | + } |
| 115 | + const generation = entryLoader.generation; |
| 116 | + const entryRows: ListRow[] = Array.from({length: displayedEntryCount}, (_, i) => ({key: `${generation}-${i}`, index: i})); |
| 117 | + if (showTerminalCreateRow) { |
| 118 | + entryRows.push({key: `${generation}-create`, index: displayedEntryCount, create: true}); |
| 119 | + } |
| 120 | + return entryRows; |
| 121 | + }); |
113 | 122 |
|
114 | 123 | async function handleNewEntry(headword: string | undefined = undefined) { |
115 | 124 | const entry = await dialogsService.createNewEntry(headword, { |
|
176 | 185 |
|
177 | 186 | </script> |
178 | 187 |
|
| 188 | +{#snippet newEntryFromSearchRow(className: string = '')} |
| 189 | + <ListItem |
| 190 | + class={cn('bg-transparent shadow-none hover:shadow-none border-2 border-dashed border-muted-foreground/40', className)} |
| 191 | + onclick={() => handleNewEntry(search)}> |
| 192 | + {#snippet icon()} |
| 193 | + <Icon icon="i-mdi-plus-thick" class="size-6 text-primary/60" /> |
| 194 | + {/snippet} |
| 195 | + <span class="font-medium text-2xl">{search}</span> |
| 196 | + <span class="text-sm text-muted-foreground">{$t`Add to dictionary`}</span> |
| 197 | + </ListItem> |
| 198 | +{/snippet} |
| 199 | + |
179 | 200 | <FabContainer> |
180 | 201 | <DevContent> |
181 | 202 | <Button |
|
200 | 221 | {:else} |
201 | 222 | <div class="h-full"> |
202 | 223 | {#if entryLoader?.totalCount === 0} |
203 | | - <div class="flex flex-col items-center justify-center h-full text-muted-foreground gap-2"> |
204 | | - <p>{pt($t`No entries found`, $t`No words found`, viewService.currentView)}</p> |
| 224 | + <div class="flex flex-col items-center justify-center h-full gap-4 px-4"> |
| 225 | + <p class="text-muted-foreground">{pt($t`No entries found`, $t`No words found`, viewService.currentView)}</p> |
205 | 226 |
|
206 | | - {#if search} |
207 | | - <Button icon="i-mdi-plus" class="" onclick={() => handleNewEntry(search)}> |
208 | | - {pt($t`Create new entry ${search}`, $t`Create new word ${search}`, viewService.currentView)} |
209 | | - </Button> |
| 227 | + {#if canCreateFromSearch} |
| 228 | + {@render newEntryFromSearchRow('max-w-md')} |
210 | 229 | {/if} |
211 | 230 | </div> |
212 | 231 | {/if} |
213 | 232 |
|
214 | 233 | <VList bind:this={vList} |
215 | | - data={indexArray} |
| 234 | + data={rows} |
216 | 235 | class="h-full p-0.5 md:pr-3 after:h-12 after:block" |
217 | | - getKey={(index: number) => `${entryLoader?.generation ?? EntryLoaderService.DEFAULT_GENERATION}-${index}`} |
| 236 | + getKey={(row: ListRow) => row.key} |
218 | 237 | bufferSize={450}> |
219 | | - {#snippet children(index: number)} |
220 | | - {#key entryLoader?.generation ?? EntryLoaderService.DEFAULT_GENERATION} |
221 | | - <!--the last item is a button to create a new entry based on the search query--> |
222 | | - {#if index !== entryLoader?.totalCount} |
223 | | - <Delayed |
224 | | - getCached={() => entryLoader?.getCachedEntryByIndex(index)} |
225 | | - load={() => entryLoader?.getOrLoadEntryByIndex(index)} |
226 | | - delay={250} |
227 | | - > |
228 | | - {#snippet children(state)} |
229 | | - {#if state.loading || !state.current} |
230 | | - <!-- we want the initial loading state and the first loading entries |
231 | | - to share the same skeletons, so there's no flicker --> |
232 | | - <EntryRow class="mb-2" skeleton={true}/> |
233 | | - {:else} |
234 | | - {@const entry = state.current} |
235 | | - <EntryMenu {entry} contextMenu> |
236 | | - <EntryRow {entry} |
237 | | - class="mb-2" |
238 | | - selected={selectedEntryId === entry.id} |
239 | | - onclick={() => onSelectEntry(entry)} |
240 | | - {previewDictionary}/> |
241 | | - </EntryMenu> |
242 | | - {/if} |
243 | | - {/snippet} |
244 | | - </Delayed> |
245 | | - {:else if search && entryLoader?.totalCount !== 0} |
246 | | - <Button icon="i-mdi-plus" onclick={() => handleNewEntry(search)}> |
247 | | - {pt($t`Create new entry ${search}`, $t`Create new word ${search}`, viewService.currentView)} |
248 | | - </Button> |
249 | | - {/if} |
250 | | - {/key} |
| 238 | + {#snippet children(row: ListRow)} |
| 239 | + {#if row.create} |
| 240 | + {@render newEntryFromSearchRow()} |
| 241 | + {:else} |
| 242 | + <Delayed |
| 243 | + getCached={() => entryLoader?.getCachedEntryByIndex(row.index)} |
| 244 | + load={() => entryLoader?.getOrLoadEntryByIndex(row.index)} |
| 245 | + delay={250} |
| 246 | + > |
| 247 | + {#snippet children(state)} |
| 248 | + {#if state.loading || !state.current} |
| 249 | + <EntryRow class="mb-2" skeleton={true}/> |
| 250 | + {:else} |
| 251 | + {@const entry = state.current} |
| 252 | + <EntryMenu {entry} contextMenu> |
| 253 | + <EntryRow {entry} |
| 254 | + class="mb-2" |
| 255 | + selected={selectedEntryId === entry.id} |
| 256 | + onclick={() => onSelectEntry(entry)} |
| 257 | + {previewDictionary}/> |
| 258 | + </EntryMenu> |
| 259 | + {/if} |
| 260 | + {/snippet} |
| 261 | + </Delayed> |
| 262 | + {/if} |
251 | 263 | {/snippet} |
252 | 264 | </VList> |
253 | 265 | </div> |
|
0 commit comments