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

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