@makolabs/ripple 0.0.1-dev.61 → 0.0.1-dev.62

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.
@@ -0,0 +1,806 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { Button, Table, Color, Size } from '../index.js';
4
+ import type { TableColumn } from '../index.js';
5
+ import { formatDate } from '../utils/dateUtils.js';
6
+ import type {
7
+ StorageAdapter,
8
+ FileItem,
9
+ Breadcrumb,
10
+ FileAction,
11
+ FileActionBatch,
12
+ FileActionSingle
13
+ } from '../adapters/storage/index.js';
14
+
15
+ let {
16
+ adapter,
17
+ startPath = '',
18
+ actions = [],
19
+ infoSection
20
+ } = $props<{
21
+ adapter: StorageAdapter;
22
+ startPath?: string;
23
+ actions?: FileAction[];
24
+ infoSection?: (props: {
25
+ selectedFiles: string[];
26
+ navToFileFolder: (fileKey: string) => void;
27
+ }) => any;
28
+ }>();
29
+
30
+ let files = $state<FileItem[]>([]);
31
+ let displayFiles = $state<FileItem[]>([]);
32
+ let currentPath = $state(startPath || '');
33
+ let isLoading = $state(true);
34
+ let isAuthenticated = $state(false);
35
+ let error = $state<string | null>(null);
36
+ let breadcrumbs = $state<Breadcrumb[]>([]);
37
+ let searchQuery = $state('');
38
+
39
+ let selectedFiles = $state<string[]>([]);
40
+
41
+ let sortState = $state<{ column: string | null; direction: 'asc' | 'desc' | 'default' | null }>({
42
+ column: null,
43
+ direction: null
44
+ });
45
+
46
+ // Add state for folder traversal
47
+ let isFetchingRecursively = $state(false);
48
+ let fileQueue = $state<FileItem[]>([]);
49
+ let processedFolders = $state<Set<string>>(new Set());
50
+ let fetchingFolderName = $state<string | null>(null);
51
+
52
+ // Format file size in human readable format
53
+ function formatFileSize(bytes: number): string {
54
+ if (bytes === 0) return '0 B';
55
+ const k = 1024;
56
+ const sizes = ['B', 'KB', 'MB', 'GB'];
57
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
58
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
59
+ }
60
+
61
+ // Function that determines if a batch action should be shown
62
+ function isBatchActionAllowed(action: FileAction): action is FileActionBatch {
63
+ if (isFetchingRecursively) return false;
64
+
65
+ return (
66
+ 'batchAction' in action &&
67
+ typeof action.batchAction === 'function' &&
68
+ action.isAllowed(allFilesAcquired)
69
+ );
70
+ }
71
+
72
+ // Function that determines if a single action should be shown for a file
73
+ function isSingleActionAllowed(action: FileAction, file: FileItem): action is FileActionSingle {
74
+ return 'action' in action && typeof action.action === 'function' && action.isAllowed(file);
75
+ }
76
+
77
+ function navigateUp() {
78
+ // Normalize currentPath and startPath by removing leading/trailing slashes
79
+ const normCurrent = currentPath.replace(/^\/+|\/+$/g, '');
80
+ const normStart = startPath.replace(/^\/+|\/+$/g, '');
81
+
82
+ if (normCurrent !== normStart) {
83
+ const segments = normCurrent.split('/');
84
+ segments.pop(); // Remove last segment
85
+ let newPath = segments.join('/');
86
+ if (!newPath) newPath = startPath || '';
87
+ navigateToFolder(newPath);
88
+ }
89
+ }
90
+
91
+ function navToFileFolder(fileKey: string) {
92
+ // Get the parent folder path by removing the filename from the key
93
+ const parentPath = fileKey.substring(0, fileKey.lastIndexOf('/'));
94
+
95
+ // Check if we're already in this folder, don't navigate in that case
96
+ if (parentPath === currentPath) {
97
+ return;
98
+ }
99
+
100
+ // Store the currently selected files before navigation
101
+ const currentSelection = [...selectedFiles];
102
+
103
+ // Make sure we preserve the selection of the file that triggered this navigation
104
+ if (!currentSelection.includes(fileKey)) {
105
+ currentSelection.push(fileKey);
106
+ }
107
+
108
+ // Navigate to folder, then restore selection
109
+ navigateToFolder(parentPath);
110
+
111
+ // In case the navigateToFolder resets selection, ensure this file is still selected
112
+ // after a short delay to allow the folder contents to load
113
+ setTimeout(() => {
114
+ if (!selectedFiles.includes(fileKey)) {
115
+ selectedFiles = [...selectedFiles, fileKey];
116
+
117
+ // Update table selection as well
118
+ const fileItem = files.find((f) => f.key === fileKey);
119
+ if (fileItem && !selected.some((s) => s.key === fileKey)) {
120
+ selected = [...selected, fileItem];
121
+ }
122
+ }
123
+ }, 100);
124
+ }
125
+
126
+ // On component mount, check auth status and list files
127
+ onMount(async () => {
128
+ await checkAdapterConfiguration();
129
+ });
130
+
131
+ async function checkAdapterConfiguration() {
132
+ try {
133
+ isAuthenticated = await adapter.isConfigured();
134
+
135
+ if (isAuthenticated) {
136
+ await listFiles(currentPath || startPath);
137
+ }
138
+ } catch (err) {
139
+ console.error('Error checking adapter configuration:', err);
140
+ isAuthenticated = false;
141
+ error = 'Failed to initialize storage adapter';
142
+ }
143
+ }
144
+
145
+ async function authenticate() {
146
+ if (adapter.authenticate) {
147
+ try {
148
+ // Use the adapter's method to set the reopening flag
149
+ if (adapter.setReopenFlag) {
150
+ adapter.setReopenFlag();
151
+ }
152
+
153
+ await adapter.authenticate();
154
+ // The adapter's authenticate method might redirect, so this may not complete
155
+ isAuthenticated = await adapter.isConfigured();
156
+ if (isAuthenticated) {
157
+ await listFiles(currentPath || startPath);
158
+ }
159
+ } catch (err) {
160
+ console.error('Authentication error:', err);
161
+ error = 'Failed to authenticate with storage provider';
162
+ }
163
+ } else {
164
+ error = 'This storage provider does not support authentication';
165
+ }
166
+ }
167
+
168
+ // Loading files using the adapter
169
+ async function listFiles(path: string) {
170
+ isLoading = true;
171
+ error = null;
172
+
173
+ // Remember the previous selection before loading new files
174
+ const previousSelectedFiles = [...selectedFiles];
175
+ const previousFileQueue = [...fileQueue];
176
+
177
+ try {
178
+ // Only pass the searchQuery if it's not empty
179
+ const result = await adapter.list(path, searchQuery.trim() ? searchQuery : undefined);
180
+
181
+ files = result.files;
182
+ currentPath = result.currentPath;
183
+
184
+ if (result.breadcrumbs) {
185
+ breadcrumbs = result.breadcrumbs;
186
+ }
187
+
188
+ // Default sort: folders alphabetically, files by date (newest first)
189
+ sortState = { column: 'default', direction: 'default' };
190
+ displayFiles = getSortedFiles(files);
191
+
192
+ // Retain selected files that are still valid
193
+ // We need to filter out any folder paths that might no longer exist
194
+ selectedFiles = previousSelectedFiles;
195
+ fileQueue = previousFileQueue;
196
+ } catch (err) {
197
+ console.error('Error fetching files:', err);
198
+ error = err instanceof Error ? err.message : 'An unknown error occurred';
199
+ } finally {
200
+ isLoading = false;
201
+ }
202
+ }
203
+
204
+ function navigateToFolder(folderPath: string) {
205
+ // If we have the file info for this folder, cache its name in the adapter
206
+ // before navigating (useful for Google Drive adapter)
207
+ const folderFile = files.find((file) => file.key === folderPath);
208
+ if (folderFile && folderFile.name && adapter.getName() === 'Google Drive') {
209
+ // Assuming the adapter might have a setFolderName method
210
+ (adapter as any).setFolderName?.(folderPath, folderFile.name);
211
+ }
212
+
213
+ // Store the currently selected files before navigation
214
+ const previouslySelectedFiles = [...selectedFiles];
215
+
216
+ // Figure out if we're navigating back up to a parent
217
+ const isNavigatingUp = currentPath.includes(folderPath) && currentPath !== folderPath;
218
+
219
+ // If we're navigating up, make sure to keep the folder we're coming from selected
220
+ const shouldKeepCurrent = isNavigatingUp && currentPath;
221
+
222
+ // Keep track of existing fileQueue
223
+ const existingQueue = [...fileQueue];
224
+
225
+ // Navigate to the folder
226
+ listFiles(folderPath).then(() => {
227
+ // After navigation is complete, restore the selection
228
+ let filesToSelect = [];
229
+
230
+ // If navigating up, keep the current folder selected
231
+ if (shouldKeepCurrent) {
232
+ // Find the folder we just came from
233
+ const currentFolder = files.find(
234
+ (f) => f.key === currentPath || (f.isFolder && currentPath.startsWith(f.key + '/'))
235
+ );
236
+
237
+ if (currentFolder) {
238
+ filesToSelect.push(currentFolder.key);
239
+ }
240
+ }
241
+
242
+ // Also keep previously selected files that are still visible
243
+ const visibleSelectedFiles = previouslySelectedFiles.filter((key) =>
244
+ files.some((file) => file.key === key)
245
+ );
246
+
247
+ filesToSelect = [...new Set([...filesToSelect, ...visibleSelectedFiles])];
248
+
249
+ // Update selection
250
+ if (filesToSelect.length > 0) {
251
+ selectedFiles = filesToSelect;
252
+ selected = displayFiles.filter((file) => filesToSelect.includes(file.key));
253
+ }
254
+
255
+ // Restore fileQueue to ensure we keep track of all selected files
256
+ // Only keep files that are still selected
257
+ fileQueue = [...existingQueue];
258
+ });
259
+ }
260
+
261
+ async function handleRowClick(file: FileItem) {
262
+ if (file.isFolder) {
263
+ navigateToFolder(file.key);
264
+ }
265
+ }
266
+
267
+ // Sort files based on current sort state
268
+ function getSortedFiles(filesArray: FileItem[]): FileItem[] {
269
+ // Separate folders and files
270
+ const folders = filesArray.filter((file) => file.isFolder);
271
+ const files = filesArray.filter((file) => !file.isFolder);
272
+
273
+ // Handle default sort (folders alphabetically, files by date newest first)
274
+ if (sortState.column === 'default' && sortState.direction === 'default') {
275
+ // Sort folders alphabetically (A-Z)
276
+ const sortedFolders = [...folders].sort((a, b) => a.name.localeCompare(b.name));
277
+
278
+ // Sort files by date (newest first)
279
+ const sortedFiles = [...files].sort((a, b) => {
280
+ // Use createdAt if available, otherwise fallback to lastModified
281
+ const aTime = a.createdAt ? a.createdAt.getTime() : a.lastModified.getTime();
282
+ const bTime = b.createdAt ? b.createdAt.getTime() : b.lastModified.getTime();
283
+ return bTime - aTime; // Descending order (newest first)
284
+ });
285
+
286
+ // Return folders first, then files
287
+ return [...sortedFolders, ...sortedFiles];
288
+ }
289
+
290
+ // For user-specified sorting
291
+ // If no sort specified, default to sorting by name
292
+ if (!sortState.column || !sortState.direction) {
293
+ sortState = { column: 'name', direction: 'asc' };
294
+ }
295
+
296
+ // Create a comparison function based on the current sort state
297
+ const compareFiles = (a: FileItem, b: FileItem): number => {
298
+ let comparison = 0;
299
+
300
+ switch (sortState.column) {
301
+ case 'name':
302
+ comparison = a.name.localeCompare(b.name);
303
+ break;
304
+ case 'size':
305
+ comparison = a.size - b.size;
306
+ break;
307
+ case 'lastModified':
308
+ comparison = a.lastModified.getTime() - b.lastModified.getTime();
309
+ break;
310
+ default:
311
+ // Default to sorting by name
312
+ return a.name.localeCompare(b.name);
313
+ }
314
+
315
+ // Reverse for descending order
316
+ return sortState.direction === 'asc' ? comparison : -comparison;
317
+ };
318
+
319
+ // Sort folders and files separately
320
+ const sortedFolders = [...folders].sort(compareFiles);
321
+ const sortedFiles = [...files].sort(compareFiles);
322
+
323
+ // Return sorted folders followed by sorted files
324
+ return [...sortedFolders, ...sortedFiles];
325
+ }
326
+
327
+ // Handle sort column click
328
+ function handleSort(newSortState: { column: string | null; direction: 'asc' | 'desc' | null }) {
329
+ // Update the component's sort state
330
+ sortState = {
331
+ column: newSortState.column,
332
+ direction: newSortState.direction
333
+ };
334
+
335
+ // Update the display files with the new sort
336
+ displayFiles = getSortedFiles(files);
337
+ }
338
+
339
+ // Handle search
340
+ function handleSearch() {
341
+ // Make sure we're actually using the current searchQuery value
342
+ listFiles(currentPath);
343
+ }
344
+
345
+ // Clear the search and reset results
346
+ function clearSearch() {
347
+ // Only refresh if there was a previous search
348
+ if (searchQuery) {
349
+ searchQuery = '';
350
+ listFiles(currentPath);
351
+ }
352
+ }
353
+
354
+ // Effect to update the Table component's selection state when selectedFiles changes
355
+ $effect(() => {
356
+ if (displayFiles.length === 0) return;
357
+
358
+ // Get all files that should be selected based on our current selection state
359
+ const filesToSelect = displayFiles.filter((file) => isRowSelected(file));
360
+
361
+ // We need to update the Table's internal selection state without triggering additional updates
362
+ // This ensures the UI is properly updated
363
+ if (
364
+ JSON.stringify(selected.map((f) => f.key).sort()) !==
365
+ JSON.stringify(filesToSelect.map((f) => f.key).sort())
366
+ ) {
367
+ // Only update if the selection has actually changed
368
+ selected = filesToSelect;
369
+ }
370
+ });
371
+
372
+ // Track the selected items for the Table component's internal state
373
+ let selected = $state<FileItem[]>([]);
374
+
375
+ // Derived: all files to import (not folders), including recursively fetched
376
+ const allFilesAcquired = $derived.by(() => {
377
+ const selectedFileKeys = new Set(selectedFiles);
378
+
379
+ // Get files from current view that are selected and not folders
380
+ const selectedCurrentFiles = displayFiles.filter(
381
+ (f) => selectedFileKeys.has(f.key) && !f.isFolder
382
+ );
383
+
384
+ // Get files from the queue (these are explicitly added when folders are processed)
385
+ const queuedFiles = fileQueue.filter((f) => !f.isFolder);
386
+
387
+ // Combine both sources
388
+ const allFiles = [...selectedCurrentFiles, ...queuedFiles];
389
+
390
+ // Remove duplicates by key
391
+ return Array.from(new Map(allFiles.map((f) => [f.key, f])).values());
392
+ });
393
+
394
+ // Ensure our selectedFiles state is updated when the Table selection changes
395
+ function onselect(selectedItems: FileItem[]) {
396
+ // We need to differentiate between what's a user action and what's our programmatic update
397
+ // Get keys from currently selected items
398
+ const newSelectedKeys = selectedItems.map((item) => item.key);
399
+
400
+ // Update the global selection state
401
+ handleSelectByKeys(newSelectedKeys, selectedItems);
402
+ }
403
+
404
+ // Handle selection based on keys to avoid redundant code
405
+ function handleSelectByKeys(keys: string[], items: FileItem[] = []) {
406
+ // Get folder items from the selection
407
+ const folderItems = items.filter((item) => item.isFolder);
408
+
409
+ // Update the selectedFiles array
410
+ selectedFiles = keys;
411
+
412
+ // Process any newly selected folders recursively to get their contents
413
+ const newFolders = folderItems.filter((folder) => !processedFolders.has(folder.key));
414
+ newFolders.forEach((folder) => {
415
+ handleSelectFolder(folder);
416
+ });
417
+
418
+ // Update select all state
419
+ const nonFolderFiles = displayFiles.filter((file) => !file.isFolder);
420
+ }
421
+
422
+ async function fetchFilesFromFolder(folderKey: string, folderName: string) {
423
+ if (processedFolders.has(folderKey)) return;
424
+
425
+ fetchingFolderName = folderName;
426
+ processedFolders.add(folderKey);
427
+
428
+ try {
429
+ // Use the adapter to list files in this folder
430
+ const result = await adapter.list(folderKey);
431
+
432
+ // Process files in this folder
433
+ const folderItems = result.files || [];
434
+
435
+ // Add non-folder files to selection
436
+ folderItems.forEach((item: FileItem) => {
437
+ if (!item.isFolder) {
438
+ if (!selectedFiles.includes(item.key)) {
439
+ selectedFiles = [...selectedFiles, item.key];
440
+ }
441
+ // Add to fileQueue for tracking
442
+ fileQueue = [...fileQueue, item];
443
+ } else {
444
+ // Add folders to queue for reference
445
+ fileQueue = [...fileQueue, item];
446
+ }
447
+ });
448
+
449
+ // Return any folders found for further processing
450
+ return folderItems.filter((item: FileItem) => item.isFolder);
451
+ } catch (err) {
452
+ console.error(`Error fetching files from folder ${folderName}:`, err);
453
+ // Note: In a real app, you'd want to import a toast library
454
+ console.error(`Failed to fetch files from ${folderName}`);
455
+ return [];
456
+ }
457
+ }
458
+
459
+ // Handle selecting a folder to process recursively
460
+ async function handleSelectFolder(folder: FileItem) {
461
+ if (!folder.isFolder) return;
462
+
463
+ isFetchingRecursively = true;
464
+ processedFolders = new Set();
465
+ // Don't clear fileQueue entirely, it will lose files from other folders
466
+ // Store existing fileQueue
467
+ const existingQueue = [...fileQueue];
468
+
469
+ try {
470
+ // Start with the selected folder
471
+ let foldersToProcess = [folder];
472
+
473
+ // Process folders breadth-first
474
+ while (foldersToProcess.length > 0) {
475
+ const currentFolder = foldersToProcess.shift();
476
+ if (!currentFolder) continue;
477
+
478
+ const subFolders = await fetchFilesFromFolder(currentFolder.key, currentFolder.name);
479
+
480
+ if (subFolders && subFolders.length > 0) {
481
+ foldersToProcess.push(...subFolders);
482
+ }
483
+ }
484
+
485
+ // Merge new files with existing queue, avoiding duplicates
486
+ fileQueue = [
487
+ ...existingQueue,
488
+ ...fileQueue.filter(
489
+ (newItem) => !existingQueue.some((existingItem) => existingItem.key === newItem.key)
490
+ )
491
+ ];
492
+
493
+ console.log(`Selected ${selectedFiles.length} files from folders`);
494
+ } catch (err) {
495
+ console.error('Error in recursive folder processing:', err);
496
+ console.error('Failed to process some folders');
497
+ } finally {
498
+ isFetchingRecursively = false;
499
+ fetchingFolderName = null;
500
+ }
501
+ }
502
+
503
+ // Check if a file is within a selected folder by examining its key path
504
+ function isFileInSelectedFolder(fileKey: string): boolean {
505
+ if (!fileKey) return false;
506
+
507
+ // Iterate through selected folders and check if the file key starts with any selected folder's key
508
+ // This assumes folder paths are prefixes of their contained files
509
+ for (const selectedKey of selectedFiles) {
510
+ const selectedFile =
511
+ files.find((f) => f.key === selectedKey) || fileQueue.find((f) => f.key === selectedKey);
512
+
513
+ // Only check folders
514
+ if (selectedFile && selectedFile.isFolder) {
515
+ // If the file's key starts with the folder's key and has additional path elements, it's inside the folder
516
+ if (fileKey.startsWith(selectedKey) && fileKey !== selectedKey) {
517
+ return true;
518
+ }
519
+ }
520
+ }
521
+
522
+ return false;
523
+ }
524
+
525
+ const columns: TableColumn<FileItem>[] = [
526
+ {
527
+ key: 'name',
528
+ header: 'Name',
529
+ cell: NameCell,
530
+ sortable: true
531
+ },
532
+ {
533
+ key: 'size',
534
+ header: 'Size',
535
+ cell: SizeCell,
536
+ sortable: true
537
+ },
538
+ {
539
+ key: 'lastModified',
540
+ header: 'Last Modified',
541
+ cell: DateCell,
542
+ sortable: true
543
+ },
544
+ {
545
+ key: 'status',
546
+ header: 'Status',
547
+ cell: StatusCell
548
+ }
549
+ ];
550
+
551
+ async function handleUnauthenticated() {
552
+ isLoading = true;
553
+
554
+ try {
555
+ // Use the adapter's method to set the reopening flag
556
+ if (adapter.setReopenFlag) {
557
+ adapter.setReopenFlag();
558
+ }
559
+
560
+ // Try to authenticate with the adapter
561
+ const authenticated = adapter.authenticate ? await adapter.authenticate() : false;
562
+
563
+ // If authentication failed, but not due to redirection, show error message
564
+ if (!authenticated) {
565
+ console.error('Failed to authenticate with storage provider');
566
+ }
567
+
568
+ // Note: authentication usually redirects the page, so code below won't run
569
+ isLoading = false;
570
+ } catch (error) {
571
+ console.error('Authentication error:', error);
572
+ console.error('Authentication error');
573
+ isLoading = false;
574
+ }
575
+ }
576
+
577
+ // Handle "Not authorized" messages from the adapter by calling authenticate
578
+ $effect(() => {
579
+ if (error && error.includes('not authorized')) {
580
+ handleUnauthenticated();
581
+ }
582
+ });
583
+
584
+ // Calculate if a row should be treated as selected
585
+ function isRowSelected(row: FileItem): boolean {
586
+ return selectedFiles.includes(row.key) || isFileInSelectedFolder(row.key);
587
+ }
588
+
589
+ const batchActions = $derived(actions.filter(isBatchActionAllowed));
590
+
591
+ const singularActions = (file: FileItem): FileActionSingle[] => {
592
+ return actions.filter((a: FileAction) => isSingleActionAllowed(a, file)) as FileActionSingle[];
593
+ };
594
+ </script>
595
+
596
+ {#snippet StatusCell(file: FileItem)}
597
+ {#if file.isFolder}
598
+ <span class="text-default-500">-</span>
599
+ {:else}
600
+ <div class="flex items-center gap-2">
601
+ {#each singularActions(file) as action (action.label)}
602
+ {#if action.isAllowed(file)}
603
+ <Button size={Size.XS} onclick={() => action.action?.(file)}>{action.label(file)}</Button>
604
+ {/if}
605
+ {/each}
606
+ </div>
607
+ {/if}
608
+ {/snippet}
609
+
610
+ {#snippet NameCell(file: FileItem)}
611
+ <div class="flex items-center gap-2">
612
+ {#if file.isFolder}
613
+ <svg class="text-amber-500" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
614
+ <path d="M10 4H4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V8C22 6.89543 21.1046 6 20 6H12L10 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
615
+ </svg>
616
+ {:else}
617
+ <svg class="text-blue-500" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
618
+ <path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
619
+ <path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
620
+ </svg>
621
+ {/if}
622
+ <span class="font-medium">{file.name}</span>
623
+ </div>
624
+ {/snippet}
625
+
626
+ {#snippet SizeCell(file: FileItem)}
627
+ {#if file.isFolder}
628
+ <span class="text-default-500">-</span>
629
+ {:else}
630
+ <span class="font-mono">{formatFileSize(file.size)}</span>
631
+ {/if}
632
+ {/snippet}
633
+
634
+ {#snippet DateCell(file: FileItem)}
635
+ <span class="text-default-600">{formatDate(file.lastModified, 'DD.MM.YYYY HH:mm')}</span>
636
+ {/snippet}
637
+
638
+ <div class="relative flex h-[calc(100vh-100px)] px-0">
639
+ <div class="min-w-0 flex-1">
640
+ {#if !isAuthenticated && adapter.authenticate}
641
+ <div class="flex h-full flex-col items-center justify-center">
642
+ <div class="mb-4 text-center text-lg">
643
+ Authentication required to access {adapter.getName()}
644
+ </div>
645
+ <Button color={Color.PRIMARY} onclick={authenticate}>Authenticate</Button>
646
+ </div>
647
+ {:else}
648
+ <div class="mb-2 flex flex-wrap items-center justify-between border-b border-gray-100 pb-3">
649
+ <div class="flex flex-wrap items-center">
650
+ {#if breadcrumbs.length > 1}
651
+ <button
652
+ class="text-default-600 mr-1 rounded-full px-2 py-1 hover:bg-gray-100"
653
+ onclick={navigateUp}
654
+ title="Go up"
655
+ aria-label="Navigate to parent folder"
656
+ >
657
+ <svg
658
+ xmlns="http://www.w3.org/2000/svg"
659
+ width="16"
660
+ height="16"
661
+ viewBox="0 0 24 24"
662
+ fill="none"
663
+ stroke="currentColor"
664
+ stroke-width="2"
665
+ stroke-linecap="round"
666
+ stroke-linejoin="round"
667
+ >
668
+ <path d="M19 12H5" />
669
+ <path d="M12 19l-7-7 7-7" />
670
+ </svg>
671
+ </button>
672
+ {/if}
673
+ <span class="text-default-400 mx-1 text-sm">/</span>
674
+
675
+ <div class="flex flex-wrap items-center">
676
+ {#each breadcrumbs as crumb, i}
677
+ {#if i > 0}
678
+ <span class="text-default-400 mx-1 text-sm">/</span>
679
+ {/if}
680
+
681
+ {#if crumb.clickable && !crumb.current}
682
+ <button
683
+ class="cursor-pointer border-0 bg-transparent px-1 py-0 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
684
+ onclick={() => navigateToFolder(crumb.path)}
685
+ >
686
+ {crumb.name}
687
+ </button>
688
+ {:else}
689
+ <span
690
+ class={`px-1 text-sm ${crumb.current ? 'font-semibold text-gray-800' : 'text-gray-600'}`}
691
+ >
692
+ {crumb.name}
693
+ </span>
694
+ {/if}
695
+ {/each}
696
+ </div>
697
+ </div>
698
+
699
+ <div class="flex items-center gap-1">
700
+ {#if isFetchingRecursively}
701
+ <div
702
+ class="flex items-center gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-1 text-xs"
703
+ >
704
+ <div
705
+ class="h-3 w-3 animate-spin rounded-full border-2 border-amber-500 border-t-transparent"
706
+ ></div>
707
+ <span>
708
+ {#if fetchingFolderName}
709
+ Fetching: <span class="font-medium">{fetchingFolderName}</span>
710
+ {:else}
711
+ Fetching files
712
+ {/if}
713
+ <span class="ml-1 font-medium text-amber-700">
714
+ ({selectedFiles.length})
715
+ </span>
716
+ </span>
717
+ </div>
718
+ {:else}
719
+ {#each batchActions as action (action.label)}
720
+ <Button
721
+ color={Color.PRIMARY}
722
+ onclick={() => action.batchAction?.(allFilesAcquired)}
723
+ disabled={!action.isAllowed(allFilesAcquired)}
724
+ class="h-7 px-3 py-1 text-xs"
725
+ >
726
+ {action.label(allFilesAcquired)}
727
+ </Button>
728
+ {/each}
729
+ {/if}
730
+ <div class="flex items-center gap-1">
731
+ <div class="relative flex items-center">
732
+ <input
733
+ type="text"
734
+ class="h-7 w-40 rounded border border-gray-300 px-2 py-1 text-xs"
735
+ placeholder="Search files..."
736
+ value={searchQuery}
737
+ oninput={(e) => {
738
+ searchQuery = (e.target as HTMLInputElement).value;
739
+ }}
740
+ onkeydown={(e) => {
741
+ if (e.key === 'Enter') handleSearch();
742
+ }}
743
+ />
744
+ {#if searchQuery}
745
+ <button
746
+ class="absolute right-1 text-xs text-gray-400 hover:text-gray-600"
747
+ onclick={clearSearch}
748
+ title="Clear search"
749
+ >
750
+ ×
751
+ </button>
752
+ {/if}
753
+ </div>
754
+ <button
755
+ onclick={handleSearch}
756
+ class="rounded bg-gray-200 px-2 py-1 text-xs hover:bg-gray-300"
757
+ >
758
+ Search
759
+ </button>
760
+ </div>
761
+ </div>
762
+ </div>
763
+
764
+ {#if isLoading}
765
+ <div class="flex h-full items-center justify-center">
766
+ <div class="text-default-500">Loading files...</div>
767
+ </div>
768
+ {:else if error}
769
+ <div class="flex h-full flex-col items-center justify-center">
770
+ <div class="text-danger-500 mb-4">{error}</div>
771
+ <Button color={Color.PRIMARY} onclick={() => listFiles(currentPath)}>Retry</Button>
772
+ </div>
773
+ {:else if files.length === 0}
774
+ <div class="flex h-full items-center justify-center">
775
+ <div class="text-default-500">No files found in this directory</div>
776
+ </div>
777
+ {:else}
778
+ <Table
779
+ {columns}
780
+ data={displayFiles}
781
+ loading={isLoading}
782
+ bordered={false}
783
+ onrowclick={handleRowClick}
784
+ rowclass={(row) => {
785
+ let classes = row.isFolder ? 'hover:bg-amber-50 cursor-pointer' : 'hover:bg-blue-50';
786
+ if (isRowSelected(row)) {
787
+ classes += ' bg-primary-50';
788
+ }
789
+ return classes;
790
+ }}
791
+ onsort={handleSort}
792
+ selectable={true}
793
+ {onselect}
794
+ {selected}
795
+ />
796
+ {/if}
797
+ {/if}
798
+ </div>
799
+
800
+ {#if infoSection}
801
+ {@render infoSection({
802
+ selectedFiles: allFilesAcquired.map((file) => file.key),
803
+ navToFileFolder
804
+ })}
805
+ {/if}
806
+ </div>