@meaningfully/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +3 -0
  3. package/dist/App.svelte +86 -0
  4. package/dist/App.svelte.d.ts +7 -0
  5. package/dist/assets/base.css +56 -0
  6. package/dist/assets/electron.svg +10 -0
  7. package/dist/assets/main.css +171 -0
  8. package/dist/components/ApiKeyPage.svelte +128 -0
  9. package/dist/components/ApiKeyPage.svelte.d.ts +9 -0
  10. package/dist/components/ApiKeyStatus.svelte +38 -0
  11. package/dist/components/ApiKeyStatus.svelte.d.ts +7 -0
  12. package/dist/components/CsvUpload.svelte +101 -0
  13. package/dist/components/CsvUpload.svelte.d.ts +5 -0
  14. package/dist/components/DatabaseConfig.svelte +485 -0
  15. package/dist/components/DatabaseConfig.svelte.d.ts +6 -0
  16. package/dist/components/ExistingDatabases.svelte +175 -0
  17. package/dist/components/ExistingDatabases.svelte.d.ts +11 -0
  18. package/dist/components/FrontPage.svelte +18 -0
  19. package/dist/components/FrontPage.svelte.d.ts +8 -0
  20. package/dist/components/HelpPage.svelte +119 -0
  21. package/dist/components/HelpPage.svelte.d.ts +18 -0
  22. package/dist/components/Preview.svelte +37 -0
  23. package/dist/components/Preview.svelte.d.ts +9 -0
  24. package/dist/components/Results.svelte +121 -0
  25. package/dist/components/Results.svelte.d.ts +10 -0
  26. package/dist/components/SearchPage.svelte +256 -0
  27. package/dist/components/SearchPage.svelte.d.ts +9 -0
  28. package/dist/components/Table.svelte +87 -0
  29. package/dist/components/Table.svelte.d.ts +11 -0
  30. package/dist/env.d.ts +39 -0
  31. package/dist/index.d.ts +13 -0
  32. package/dist/index.js +14 -0
  33. package/dist/stores/fileDataStore.d.ts +8 -0
  34. package/dist/stores/fileDataStore.js +3 -0
  35. package/dist/style.css +1 -0
  36. package/dist/types.d.ts +60 -0
  37. package/dist/types.js +1 -0
  38. package/package.json +74 -0
@@ -0,0 +1,485 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { navigate } from 'svelte-routing';
4
+ import { debounce } from 'lodash';
5
+ import Preview from './Preview.svelte';
6
+ import { fileDataStore } from '../stores/fileDataStore';
7
+ import type { MeaningfullyAPI } from '../types';
8
+
9
+ interface Props {
10
+ validApiKeysSet: boolean;
11
+ api: MeaningfullyAPI;
12
+ };
13
+
14
+ let {
15
+ validApiKeysSet,
16
+ api
17
+ } = $props();
18
+
19
+ let fileData: any = $state(null);
20
+ let uploading = $state(false);
21
+ let error = $state('');
22
+ let selectedTextColumn = $state('');
23
+ let selectedMetadataColumns: string[] = $state([]);
24
+ let generatingPreview = $state(false);
25
+ let datasetName = $state('');
26
+ const defaultChunkSize = 100;
27
+ const defaultChunkOverlap = 20;
28
+ let chunkSize = $state(defaultChunkSize);
29
+ let chunkOverlap = $state(defaultChunkOverlap);
30
+
31
+ // Model options grouped by provider
32
+ const modelOptions: Record<string, string[]> = {
33
+ "openai": ["text-embedding-3-small", "text-embedding-3-large"],
34
+ "azure": ["text-embedding-3-small", "text-embedding-3-large"],
35
+ "ollama": ["mxbai-embed-large", "nomic-embed-text"],
36
+ "mistral": ["mistral-embed"],
37
+ "gemini": ["gemini-embedding-001"]
38
+ };
39
+
40
+ let modelProvider = $state("openai");
41
+ let modelName = $state("text-embedding-3-small");
42
+ let splitIntoSentences = $state(true);
43
+ let combineSentencesIntoChunks = $state(true);
44
+ let previewData: Array<Record<string, any>> = $state([]);
45
+ let costEstimate: number = $state(0);
46
+ let tokenCount: number = $state(0);
47
+ let pricePer1M: number = $state(0);
48
+ let isCollapsed = $state(true);
49
+
50
+ // Update model name when provider changes
51
+ $effect(() => {
52
+ if (modelProvider && modelOptions[modelProvider]) {
53
+ modelName = modelOptions[modelProvider][0];
54
+ }
55
+ });
56
+
57
+ // Progress tracking
58
+ let progress = $state(0);
59
+ let progressTotal = $state(100);
60
+ // let elapsedTimeMs = $state(0);
61
+ let estimatedTimeRemainingMs: number | null = $state(null);
62
+
63
+ // Helper function to format time in human-readable format
64
+ const formatTime = (timeMs: number | null): string => {
65
+ if (!timeMs || timeMs < 1000) return '<1s';
66
+
67
+ const totalSeconds = Math.floor(timeMs / 1000);
68
+ const hours = Math.floor(totalSeconds / 3600);
69
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
70
+ const seconds = totalSeconds % 60;
71
+
72
+ let result = '';
73
+ if (hours > 0) result += `${hours}h`;
74
+ if (minutes > 0) result += `${minutes}m`;
75
+ if (seconds > 0 || result === '') result += `${seconds}s`;
76
+
77
+ return result;
78
+ };
79
+
80
+ onMount(() => {
81
+ // Subscribe to the file data store
82
+ const unsubscribe = fileDataStore.subscribe((data) => {
83
+ if (!data) {
84
+ navigate('/'); // Redirect back to home if no file data
85
+ return;
86
+ }
87
+ fileData = data;
88
+ datasetName = fileData.name.replace(/\.csv$/, '');
89
+ });
90
+
91
+ // Ensure cleanup on destroy
92
+ // https://svelte.dev/docs/svelte/lifecycle-hooks
93
+ // "If a function is returned from onMount, it will be called when the component is unmounted."
94
+ return unsubscribe;
95
+ });
96
+
97
+ // Poll the backend every second for upload progress
98
+ const pollProgress = async () => {
99
+ if (!uploading) return;
100
+ try {
101
+ const result = await api.getUploadProgress();
102
+ progress = result.progress;
103
+ progressTotal = result.total;
104
+ // // elapsedTimeMs = result.elapsedTimeMs;
105
+ estimatedTimeRemainingMs = result.estimatedTimeRemainingMs;
106
+ } catch(e) {
107
+ console.error("Error fetching progress:", e);
108
+ }
109
+ if (uploading) setTimeout(pollProgress, 1000);
110
+ };
111
+
112
+ const generatePreview = async () => {
113
+ if (!fileData || !selectedTextColumn) {
114
+ error = 'Select a text column (or maybe something went wrong with the file you chose)';
115
+ return;
116
+ }
117
+
118
+ try {
119
+ error = '';
120
+ generatingPreview = true;
121
+ const previewResponse = await api.generatePreviewData({
122
+ fileContent: fileData.fileContent,
123
+ fileName: fileData.name,
124
+ datasetName,
125
+ description: 'TK',
126
+ textColumns: [selectedTextColumn],
127
+ metadataColumns: selectedMetadataColumns.map(c => c),
128
+ splitIntoSentences,
129
+ combineSentencesIntoChunks,
130
+ sploderMaxSize: 100,
131
+ modelName,
132
+ modelProvider,
133
+ chunkSize,
134
+ chunkOverlap
135
+ });
136
+
137
+ if (previewResponse.success) {
138
+ costEstimate = previewResponse.estimatedPrice;
139
+ tokenCount = previewResponse.tokenCount;
140
+ pricePer1M = previewResponse.pricePer1M;
141
+ previewData = previewResponse.nodes.map((result: Record<string, any>) => ({
142
+ ...result.metadata,
143
+ [selectedTextColumn]: result.text
144
+ }));
145
+ } else {
146
+ error = 'Preview generation failed';
147
+ console.error('Preview generation failed:', previewResponse);
148
+ }
149
+ } catch (e: any) {
150
+ error = e.message;
151
+ } finally {
152
+ generatingPreview = false;
153
+ }
154
+ };
155
+
156
+ const handleUpload = async () => {
157
+ if (!fileData || !selectedTextColumn) {
158
+ error = 'Select a text column (or maybe something went wrong with the file you chose)';
159
+ return;
160
+ }
161
+
162
+ try {
163
+ uploading = true;
164
+ error = '';
165
+
166
+ // Reset timing variables
167
+ // elapsedTimeMs = 0;
168
+ estimatedTimeRemainingMs = null;
169
+
170
+ // Start polling for progress
171
+ pollProgress();
172
+ const uploadResponse = await api.uploadCsv({
173
+ fileContent: fileData.fileContent,
174
+ fileName: fileData.name,
175
+ datasetName,
176
+ description: 'TK',
177
+ textColumns: [selectedTextColumn],
178
+ metadataColumns: selectedMetadataColumns.map(c => c),
179
+ splitIntoSentences,
180
+ combineSentencesIntoChunks,
181
+ sploderMaxSize: 100,
182
+ chunkSize,
183
+ chunkOverlap,
184
+ modelName,
185
+ modelProvider
186
+ });
187
+
188
+ if (uploadResponse.success) {
189
+ navigate("/search/" + uploadResponse.documentSetId);
190
+ } else {
191
+ error = 'Upload failed';
192
+ }
193
+ } catch (e: any) {
194
+ error = e.message;
195
+ } finally {
196
+ uploading = false;
197
+ // Reset timing variables when upload finishes
198
+ // elapsedTimeMs = 0;
199
+ estimatedTimeRemainingMs = null;
200
+ }
201
+ };
202
+
203
+ const toggleMetadataColumn = (column: string) => {
204
+ if (column === selectedTextColumn) return;
205
+ const index = selectedMetadataColumns.indexOf(column);
206
+ if (index === -1) {
207
+ selectedMetadataColumns = [...selectedMetadataColumns, column];
208
+ } else {
209
+ selectedMetadataColumns = selectedMetadataColumns.filter(c => c !== column);
210
+ }
211
+ };
212
+
213
+ const toggleTextHandlingSectionCollapse = () => {
214
+ isCollapsed = !isCollapsed;
215
+ };
216
+
217
+ const debouncedGeneratePreview = debounce(generatePreview, 1000);
218
+
219
+ $effect(() => {
220
+ // Synchronously read all values to establish dependencies
221
+ const params = {
222
+ chunkSize,
223
+ chunkOverlap,
224
+ splitIntoSentences,
225
+ combineSentencesIntoChunks,
226
+ selectedTextColumn,
227
+ selectedMetadataColumns,
228
+ modelName,
229
+ modelProvider,
230
+ };
231
+
232
+ // Only trigger preview if we have required values
233
+ if (params.selectedTextColumn || params.selectedMetadataColumns.length) {
234
+ debouncedGeneratePreview();
235
+ }
236
+ });
237
+
238
+ const goBack = () => {
239
+ navigate('/');
240
+ };
241
+ </script>
242
+
243
+ {#if fileData}
244
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
245
+ <div class="flex items-center justify-between">
246
+ <h2 class="text-xl font-semibold">The Details: Set up semantic search for "{fileData.name}"</h2>
247
+ <button
248
+ onclick={goBack}
249
+ class="text-sm text-gray-600 hover:text-gray-800 underline"
250
+ >
251
+ ← Choose A Different File
252
+ </button>
253
+ </div>
254
+
255
+ <div class="text-sm text-gray-600">
256
+ File size: {Math.round(fileData.size / 1024)} KB •
257
+ {fileData.availableColumns.length} columns detected
258
+ </div>
259
+ </div>
260
+
261
+ <div data-testid="csv-upload-settings">
262
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
263
+ <div class="space-y-2">
264
+ <label class="block text-sm font-medium text-gray-700">
265
+ Give the spreadsheet a name:
266
+ <input
267
+ type="text"
268
+ data-testid="dataset-name-input"
269
+ bind:value={datasetName}
270
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500"
271
+ />
272
+ </label>
273
+ <p class="text-xs text-gray-500">
274
+ The name is just for you. Use something that will help you remember what this spreadsheet is.
275
+ </p>
276
+ </div>
277
+
278
+ <div class="space-y-2">
279
+ <label class="block text-sm font-medium text-gray-700">
280
+ What embedding provider should we use?
281
+ <select
282
+ bind:value={modelProvider}
283
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500">
284
+ <option value="openai">OpenAI</option>
285
+ <option value="azure">Azure OpenAI</option>
286
+ <option value="ollama">Ollama</option>
287
+ <option value="mistral">Mistral AI</option>
288
+ <option value="gemini">Google Gemini</option>
289
+ </select>
290
+ </label>
291
+ <p class="text-xs text-gray-500">
292
+ The provider for the embedding model.
293
+ </p>
294
+ </div>
295
+
296
+ <div class="space-y-2">
297
+ <label class="block text-sm font-medium text-gray-700">
298
+ What embedding model should we use?
299
+ <select
300
+ bind:value={modelName}
301
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500">
302
+ {#each modelOptions[modelProvider] as modelNameChoice}
303
+ <option value={modelNameChoice}>{modelNameChoice}</option>
304
+ {/each}
305
+ </select>
306
+ </label>
307
+ <p class="text-xs text-gray-500">
308
+ The model used to generate embeddings for the text.
309
+ </p>
310
+ </div>
311
+ </div>
312
+
313
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
314
+ <h3>Column Configuration</h3>
315
+ <div class="space-y-4">
316
+ <div class="space-y-2">
317
+ <label class="block text-sm font-medium text-gray-700">
318
+ Which column holds the text you want to search?
319
+ <select
320
+ bind:value={selectedTextColumn}
321
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500"
322
+ data-testid="column-to-embed-select"
323
+ >
324
+ <option value="">Select a column...</option>
325
+ {#each fileData.availableColumns as column}
326
+ <option value={column}>{column}</option>
327
+ {/each}
328
+ </select>
329
+ </label>
330
+ </div>
331
+
332
+ <div class="space-y-2">
333
+ <p class="block text-sm font-medium text-gray-700">
334
+ Which other columns should be shown in the results, and available for filtering?
335
+ </p>
336
+ <p class="text-xs text-gray-500">
337
+ For instance, if your spreadsheet has a <code>Category</code> column, you might want to select it so you can filter by it when searching. If it has a
338
+ <code>URL</code>, you might select it so you can click through to the original.
339
+ </p>
340
+ <div class="flex flex-wrap gap-2">
341
+ {#each fileData.availableColumns as column}
342
+ <label class="inline-flex items-center">
343
+ <input
344
+ type="checkbox"
345
+ id={`metadata-${column}`}
346
+ checked={selectedMetadataColumns.includes(column)}
347
+ disabled={column === selectedTextColumn}
348
+ onchange={() => toggleMetadataColumn(column)}
349
+ class="rounded border-gray-300 text-violet-600 shadow-sm focus:border-violet-500 focus:ring-violet-500"
350
+ />
351
+ <span class="ml-2 text-sm text-gray-700">{column}</span>
352
+ </label>
353
+ {/each}
354
+ </div>
355
+ </div>
356
+ </div>
357
+ </div>
358
+
359
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
360
+ <h3><button onclick={toggleTextHandlingSectionCollapse}>Text Handling Options {isCollapsed ? '›' : '⌄'}</button></h3>
361
+
362
+ {#if !isCollapsed}
363
+ <div class="space-y-6">
364
+ <div class="space-y-2 flex flex-wrap gap-2">
365
+ <p>Each text might contain multiple ideas. Meaningfully tries to represent these ideas at multiple levels, returning search results of a 1, 2 or 3 sentences long</p>
366
+ <label class="inline-flex items-center">
367
+ <input
368
+ type="checkbox"
369
+ bind:checked={splitIntoSentences}
370
+ class="rounded border-gray-300 text-violet-600 shadow-sm focus:border-violet-500 focus:ring-violet-500"
371
+ />
372
+ <span class="ml-2 text-sm text-gray-700">Split into sentences?</span>
373
+ </label>
374
+ <label class="inline-flex items-center">
375
+ <input
376
+ type="checkbox"
377
+ bind:checked={combineSentencesIntoChunks}
378
+ class="rounded border-gray-300 text-violet-600 shadow-sm focus:border-violet-500 focus:ring-violet-500"
379
+ />
380
+ <span class="ml-2 text-sm text-gray-700">Combine sentences into chunks?</span>
381
+ </label>
382
+ </div>
383
+
384
+ <div class="space-y-2">
385
+ <h3>Split long sentences into chunks</h3>
386
+ <div class="flex flex-wrap gap-2">
387
+ <div class="inline-flex max-w-md p-2">
388
+ <label class="block text-sm font-medium text-gray-700">
389
+ Chunk size:
390
+ <input
391
+ type="number"
392
+ bind:value={chunkSize}
393
+ min="50"
394
+ max="1000"
395
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500"
396
+ />
397
+ </label>
398
+ </div>
399
+ <div class="inline-flex max-w-md p-2">
400
+ <label class="block text-sm font-medium text-gray-700">
401
+ Chunk overlap:
402
+ <input
403
+ type="number"
404
+ bind:value={chunkOverlap}
405
+ min="0"
406
+ max="100"
407
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-violet-500 focus:ring-violet-500"
408
+ />
409
+ </label>
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ {/if}
415
+ </div>
416
+
417
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
418
+ {#key costEstimate}
419
+ {#key tokenCount}
420
+ {#key pricePer1M }
421
+ {#if tokenCount > 0 && costEstimate > 0}
422
+ <div data-testid="cost-estimate">
423
+ <h3>Cost Estimate</h3>
424
+ <p class="text-sm text-gray-600">
425
+ Estimated cost: <strong>${costEstimate.toFixed(4)}</strong>
426
+ ({tokenCount.toLocaleString()} tokens at ${pricePer1M !== undefined ? pricePer1M.toFixed(2) : 'N/A'} per 1M tokens)
427
+ </p>
428
+ </div>
429
+ {/if}
430
+ {/key}
431
+ {/key}
432
+ {/key}
433
+
434
+ {#key previewData}
435
+ {#key selectedTextColumn}
436
+ {#key selectedMetadataColumns}
437
+ {#if (previewData.length > 0 || generatingPreview) && selectedTextColumn}
438
+ <Preview
439
+ previewData={previewData}
440
+ textColumn={selectedTextColumn}
441
+ metadataColumns={selectedMetadataColumns}
442
+ loading={generatingPreview}
443
+ />
444
+ {/if}
445
+ {/key}
446
+ {/key}
447
+ {/key}
448
+
449
+ <button
450
+ onclick={handleUpload}
451
+ data-testid="upload-button"
452
+ disabled={!selectedTextColumn || uploading || !validApiKeysSet}
453
+ class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-violet-600 hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-violet-500 disabled:opacity-50 disabled:cursor-not-allowed"
454
+ >
455
+ {uploading ? 'Uploading...' : 'Upload Spreadsheet'}
456
+ </button>
457
+
458
+ {#if uploading}
459
+ <p>This could take a few minutes. Go get a cup of coffee or re-arrange your MySpace Top 8.</p>
460
+ <div class="w-full bg-gray-200 rounded-full h-4 mt-2">
461
+ <div
462
+ class="bg-violet-600 h-4 rounded-full transition-all duration-500"
463
+ style="width: {Math.min(100, Math.round((progress / progressTotal) * 100))}%"
464
+ ></div>
465
+ </div>
466
+ <div class="text-sm text-gray-600 mt-2 space-y-1">
467
+ <p>
468
+ Progress: {progress <= 5 ? 'processing upload' : ( progress >= 95 ? 'finishing up' : 'embedding') } ({Math.round((progress / progressTotal) * 100)}%)
469
+ {#if estimatedTimeRemainingMs !== null}
470
+ | {formatTime(estimatedTimeRemainingMs)} remaining (est.)
471
+ {/if}
472
+ </p>
473
+ </div>
474
+ {/if }
475
+ </div>
476
+ </div>
477
+ {:else}
478
+ <div class="bg-white p-6 rounded-lg shadow space-y-6 text-black mb-10">
479
+ <p>Loading file data...</p>
480
+ </div>
481
+ {/if}
482
+
483
+ {#if error}
484
+ <div class="mt-4 text-red-600">{error}</div>
485
+ {/if}
@@ -0,0 +1,6 @@
1
+ declare const DatabaseConfig: import("svelte").Component<{
2
+ validApiKeysSet: any;
3
+ api: any;
4
+ }, {}, "">;
5
+ type DatabaseConfig = ReturnType<typeof DatabaseConfig>;
6
+ export default DatabaseConfig;
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import type { DocumentSet, MeaningfullyAPI } from '../types.js';
4
+ import { Link } from 'svelte-routing';
5
+
6
+ interface Props {
7
+ api: MeaningfullyAPI;
8
+ }
9
+ let { api }: Props = $props();
10
+
11
+ let documentSets: DocumentSet[] = $state([]);
12
+ let loading = $state(true);
13
+ let error: string | null = $state(null);
14
+ let hidden = $state(false);
15
+ let currentPage = $state(1);
16
+ let totalPages = $state(1);
17
+ let totalDocuments = $state(0);
18
+ const pageSize = 10;
19
+
20
+ export function hide() {
21
+ hidden = true;
22
+ }
23
+
24
+ export function show() {
25
+ hidden = false;
26
+ }
27
+
28
+ export async function loadDocumentSets(page: number = 1) { try {
29
+ loading = true;
30
+ const result = await api.listDocumentSets(page, pageSize);
31
+ documentSets = result.documents.map(set => ({
32
+ ...set,
33
+ uploadDate: new Date(set.uploadDate)
34
+ }));
35
+ totalDocuments = result.total;
36
+ totalPages = Math.ceil(totalDocuments / pageSize);
37
+ } catch (e) {
38
+ error = e instanceof Error ? e.message : 'Failed to load document sets';
39
+ } finally {
40
+ loading = false;
41
+ }
42
+ }
43
+
44
+ async function handleDelete(documentSetId: number, name: string, e: Event) {
45
+ e.preventDefault();
46
+ if (confirm(`Are you sure you want to delete "${name}"? This cannot be undone.`)) {
47
+ try {
48
+ await api.deleteDocumentSet(documentSetId);
49
+ await loadDocumentSets(currentPage);
50
+ } catch (e) {
51
+ error = e instanceof Error ? e.message : 'Failed to delete document set';
52
+ }
53
+ }
54
+ }
55
+
56
+ function nextPage(e: Event) {
57
+ e.preventDefault();
58
+ if (currentPage < totalPages) {
59
+ currentPage++;
60
+ loadDocumentSets(currentPage);
61
+ }
62
+ }
63
+
64
+ function previousPage(e: Event) {
65
+ e.preventDefault();
66
+ if (currentPage > 1) {
67
+ currentPage--;
68
+ loadDocumentSets(currentPage);
69
+ }
70
+ }
71
+
72
+ onMount(() => loadDocumentSets(1));
73
+ </script>
74
+
75
+ {#if hidden}
76
+ <div class="my-10 flex justify-center p-8">
77
+ </div>
78
+ {:else if loading}
79
+ <div class="my-10 flex justify-center p-8">
80
+ <div class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"></div>
81
+ </div>
82
+ {:else if error}
83
+ <div class="my-10 p-4 bg-red-100 text-red-700 rounded-md">
84
+ {error}
85
+ </div>
86
+ {:else}
87
+ <div class="my-10 bg-white p-6 rounded-lg shadow space-y-6 text-black" data-testid="existing-spreadsheets">
88
+ <h2 class="text-2xl font-bold">Existing Spreadsheets</h2>
89
+ {#if documentSets.length === 0}
90
+ <p class="text-gray-500">No spreadsheets found. Upload one to get started.</p>
91
+ {:else}
92
+ <div class="overflow-x-auto">
93
+ <table class="min-w-full table-auto">
94
+ <thead>
95
+ <tr class="">
96
+ <th class="px-4 py-2 text-left">Name</th>
97
+ <th class="px-4 py-2 text-left">Upload Date</th>
98
+ <th class="px-4 py-2 text-left">Documents</th>
99
+ <th class="px-4 py-2 text-left">Parameters</th>
100
+ <th class="px-4 py-2 text-left"><span class="sr-only">Actions</span></th>
101
+ </tr>
102
+ </thead>
103
+ <tbody>
104
+ {#each documentSets as set}
105
+ <tr
106
+ class="border-t hover:bg-gray-50 transition-colors"
107
+ data-testid="existing-spreadsheet-row"
108
+ >
109
+ <td class="px-4 py-2 font-medium">
110
+ <Link
111
+ to={`/search/${set.documentSetId}`}
112
+ class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600"
113
+ >
114
+ {set.name}
115
+ </Link>
116
+ </td>
117
+ <td class="px-4 py-2 text-gray-600">{set.uploadDate.toLocaleString()}</td>
118
+ <td class="px-4 py-2 text-gray-600">
119
+ {set.totalDocuments.toLocaleString()}
120
+ </td>
121
+ <td class="px-4 py-2">
122
+ {#if Object.keys(set.parameters).length > 0}
123
+ <details>
124
+ <summary class="cursor-pointer text-sm text-blue-600">View Parameters</summary>
125
+ <pre class="mt-2 p-2 bg-gray-50 rounded text-sm">{JSON.stringify(set.parameters, null, 2)}</pre>
126
+ </details>
127
+ {:else}
128
+ <span class="text-gray-400">None</span>
129
+ {/if}
130
+ </td>
131
+ <td class="px-4 py-2">
132
+ <button
133
+ type="button"
134
+ class="text-gray-500 hover:text-red-600 transition-colors"
135
+ aria-label="Delete {set.name}"
136
+ title="Delete {set.name}"
137
+ onclick={(e) => handleDelete(set.documentSetId, set.name, e)}
138
+ >
139
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
140
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
141
+ </svg>
142
+ </button>
143
+ </td>
144
+ </tr>
145
+ {/each}
146
+ </tbody>
147
+ </table>
148
+
149
+ {#if totalPages > 1}
150
+ <div class="mt-4 flex items-center justify-between px-4" data-testid="pagination-control">
151
+ <div class="text-sm text-gray-700">
152
+ Showing page {currentPage} of {totalPages}
153
+ </div>
154
+ <div class="flex gap-2">
155
+ <button
156
+ class="px-4 py-2 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
157
+ onclick={previousPage}
158
+ disabled={currentPage === 1}
159
+ >
160
+ Previous
161
+ </button>
162
+ <button
163
+ class="px-4 py-2 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
164
+ onclick={nextPage}
165
+ disabled={currentPage === totalPages}
166
+ >
167
+ Next
168
+ </button>
169
+ </div>
170
+ </div>
171
+ {/if}
172
+ </div>
173
+ {/if}
174
+ </div>
175
+ {/if}
@@ -0,0 +1,11 @@
1
+ import type { MeaningfullyAPI } from '../types.js';
2
+ interface Props {
3
+ api: MeaningfullyAPI;
4
+ }
5
+ declare const ExistingDatabases: import("svelte").Component<Props, {
6
+ hide: () => void;
7
+ show: () => void;
8
+ loadDocumentSets: (page?: number) => Promise<void>;
9
+ }, "">;
10
+ type ExistingDatabases = ReturnType<typeof ExistingDatabases>;
11
+ export default ExistingDatabases;