|
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'; |
|
19 | 20 | import Delayed from '$lib/components/Delayed.svelte'; |
20 | 21 | import {EntryLoaderService} from '$lib/services/entry-loader-service.svelte'; |
21 | 22 | import {onDestroy, untrack} from 'svelte'; |
| 23 | + import {useViewService} from '$lib/views/view-service.svelte'; |
| 24 | + import {pt} from '$lib/views/view-text'; |
22 | 25 |
|
23 | 26 | let { |
24 | 27 | search = '', |
|
49 | 52 | const miniLcmApi = $derived(projectContext.maybeApi); |
50 | 53 | const dialogsService = useDialogsService(); |
51 | 54 | const projectEventBus = useProjectEventBus(); |
| 55 | + const viewService = useViewService(); |
52 | 56 |
|
53 | 57 | // The closures maybe need to be created OUTSIDE untrack so they maintain reactivity |
54 | 58 | const deps = { |
|
58 | 62 | }; |
59 | 63 |
|
60 | 64 | let entryLoader = $derived(!miniLcmApi ? undefined : untrack(() => new EntryLoaderService(miniLcmApi, deps))); |
| 65 | + const displayedEntryCount = $derived(entryLoader?.totalCount ?? 0); |
61 | 66 |
|
62 | 67 | // Destroy the previous entryLoader when a new one is created |
63 | 68 | watch( |
|
99 | 104 | // Generate a random number of skeleton rows |
100 | 105 | const skeletonRowCount = Math.ceil(Math.random() * 3) + 3; |
101 | 106 |
|
102 | | - // Generate index array for virtual list. |
103 | | - // We use a small number of skeletons if the total count is not yet known |
104 | | - // to avoid a "white phase" between initial load and list initialization. |
105 | | - const indexArray = $derived( |
106 | | - entryLoader?.totalCount !== undefined |
107 | | - ? Array.from({ length: entryLoader.totalCount }, (_, i) => i) |
108 | | - : Array.from({ length: skeletonRowCount }, (_, i) => i) |
109 | | - ); |
| 107 | + const canCreateFromSearch = $derived(search?.trim() && !disableNewEntry); |
| 108 | + const showTerminalCreateRow = $derived(canCreateFromSearch && displayedEntryCount > 0); |
110 | 109 |
|
111 | | - async function handleNewEntry() { |
112 | | - const entry = await dialogsService.createNewEntry(undefined, { |
| 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 | + }); |
| 122 | +
|
| 123 | + async function handleNewEntry(headword: string | undefined = undefined) { |
| 124 | + const entry = await dialogsService.createNewEntry(headword, { |
113 | 125 | publishIn: publication ? [publication] : [], |
114 | 126 | }, { |
115 | 127 | semanticDomains: semanticDomain ? [semanticDomain] : [], |
|
173 | 185 |
|
174 | 186 | </script> |
175 | 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 | + |
176 | 200 | <FabContainer> |
177 | 201 | <DevContent> |
178 | 202 | <Button |
|
184 | 208 | /> |
185 | 209 | </DevContent> |
186 | 210 | {#if !disableNewEntry} |
187 | | - <PrimaryNewEntryButton onclick={handleNewEntry} shortForm /> |
| 211 | + <PrimaryNewEntryButton onclick={() => handleNewEntry()} shortForm /> |
188 | 212 | {/if} |
189 | 213 | </FabContainer> |
190 | 214 |
|
|
197 | 221 | {:else} |
198 | 222 | <div class="h-full"> |
199 | 223 | {#if entryLoader?.totalCount === 0} |
200 | | - <div class="flex items-center justify-center h-full text-muted-foreground"> |
201 | | - <p>{$t`No entries found`}</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> |
| 226 | + |
| 227 | + {#if canCreateFromSearch} |
| 228 | + {@render newEntryFromSearchRow('max-w-md')} |
| 229 | + {/if} |
202 | 230 | </div> |
203 | 231 | {/if} |
204 | 232 |
|
205 | 233 | <VList bind:this={vList} |
206 | | - data={indexArray} |
| 234 | + data={rows} |
207 | 235 | class="h-full p-0.5 md:pr-3 after:h-12 after:block" |
208 | | - getKey={(index: number) => `${entryLoader?.generation ?? EntryLoaderService.DEFAULT_GENERATION}-${index}`} |
| 236 | + getKey={(row: ListRow) => row.key} |
209 | 237 | bufferSize={450}> |
210 | | - {#snippet children(index: number)} |
211 | | - {#key entryLoader?.generation ?? EntryLoaderService.DEFAULT_GENERATION} |
| 238 | + {#snippet children(row: ListRow)} |
| 239 | + {#if row.create} |
| 240 | + {@render newEntryFromSearchRow()} |
| 241 | + {:else} |
212 | 242 | <Delayed |
213 | | - getCached={() => entryLoader?.getCachedEntryByIndex(index)} |
214 | | - load={() => entryLoader?.getOrLoadEntryByIndex(index)} |
| 243 | + getCached={() => entryLoader?.getCachedEntryByIndex(row.index)} |
| 244 | + load={() => entryLoader?.getOrLoadEntryByIndex(row.index)} |
215 | 245 | delay={250} |
216 | 246 | > |
217 | 247 | {#snippet children(state)} |
218 | 248 | {#if state.loading || !state.current} |
219 | | - <!-- we want the initial loading state and the first loading entries |
220 | | - to share the same skeletons, so there's no flicker --> |
221 | | - <EntryRow class="mb-2" skeleton={true} /> |
| 249 | + <EntryRow class="mb-2" skeleton={true}/> |
222 | 250 | {:else} |
223 | 251 | {@const entry = state.current} |
224 | 252 | <EntryMenu {entry} contextMenu> |
|
231 | 259 | {/if} |
232 | 260 | {/snippet} |
233 | 261 | </Delayed> |
234 | | - {/key} |
| 262 | + {/if} |
235 | 263 | {/snippet} |
236 | 264 | </VList> |
237 | 265 | </div> |
|
0 commit comments