@myrialabs/clopen 0.2.3 → 0.2.5

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 (53) hide show
  1. package/backend/engine/adapters/claude/stream.ts +107 -0
  2. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  3. package/backend/engine/adapters/opencode/stream.ts +81 -1
  4. package/backend/engine/types.ts +17 -0
  5. package/backend/git/git-service.ts +2 -1
  6. package/backend/ws/git/commit-message.ts +108 -0
  7. package/backend/ws/git/index.ts +3 -1
  8. package/backend/ws/system/index.ts +7 -1
  9. package/backend/ws/system/operations.ts +28 -2
  10. package/backend/ws/user/crud.ts +6 -3
  11. package/frontend/App.svelte +3 -0
  12. package/frontend/components/auth/SetupPage.svelte +2 -2
  13. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  14. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  15. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  16. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  17. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  18. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  19. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  20. package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
  21. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  22. package/frontend/components/files/FileViewer.svelte +11 -143
  23. package/frontend/components/git/BranchManager.svelte +143 -155
  24. package/frontend/components/git/CommitForm.svelte +61 -11
  25. package/frontend/components/git/DiffViewer.svelte +50 -130
  26. package/frontend/components/git/FileChangeItem.svelte +22 -0
  27. package/frontend/components/settings/SettingsModal.svelte +1 -1
  28. package/frontend/components/settings/SettingsView.svelte +1 -1
  29. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  30. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  31. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  32. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  33. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  34. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  35. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  36. package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
  37. package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
  38. package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
  39. package/frontend/main.ts +4 -0
  40. package/frontend/stores/core/files.svelte.ts +15 -1
  41. package/frontend/stores/features/settings.svelte.ts +13 -2
  42. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  43. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  44. package/frontend/stores/ui/update.svelte.ts +45 -4
  45. package/frontend/utils/file-type.ts +68 -0
  46. package/index.html +1 -0
  47. package/package.json +1 -1
  48. package/shared/constants/binary-extensions.ts +40 -0
  49. package/shared/types/git.ts +15 -0
  50. package/shared/types/messaging/tool.ts +1 -0
  51. package/shared/types/stores/settings.ts +12 -0
  52. package/shared/utils/file-type-detection.ts +9 -1
  53. package/static/manifest.json +16 -0
@@ -2,7 +2,7 @@
2
2
  import Modal from '$frontend/components/common/overlay/Modal.svelte';
3
3
  import Dialog from '$frontend/components/common/overlay/Dialog.svelte';
4
4
  import Icon from '$frontend/components/common/display/Icon.svelte';
5
- import DiffBlock from '$frontend/components/chat/tools/components/DiffBlock.svelte';
5
+ import ConflictResolutionModal from './ConflictResolutionModal.svelte';
6
6
  import TimelineGraph from './timeline/TimelineGraph.svelte';
7
7
  import { sessionState, loadMessagesForSession } from '$frontend/stores/core/sessions.svelte';
8
8
  import { appState } from '$frontend/stores/core/app.svelte';
@@ -38,9 +38,7 @@
38
38
  // Conflict resolution modal state
39
39
  let showConflictModal = $state(false);
40
40
  let conflictList = $state<RestoreConflict[]>([]);
41
- let conflictResolutions = $state<ConflictResolution>({});
42
41
  let conflictCheckingNode = $state<GraphNode | null>(null);
43
- let expandedDiffs = $state<Set<string>>(new Set());
44
42
 
45
43
  // Graph visualization state
46
44
  let graphNodes = $state<GraphNode[]>([]);
@@ -207,12 +205,6 @@
207
205
  if (conflictCheck.hasConflicts) {
208
206
  // Show conflict resolution modal
209
207
  conflictList = conflictCheck.conflicts;
210
- conflictResolutions = {};
211
- expandedDiffs = new Set();
212
- // Default all to 'keep' (safer default)
213
- for (const conflict of conflictCheck.conflicts) {
214
- conflictResolutions[conflict.filepath] = 'keep';
215
- }
216
208
  conflictCheckingNode = node;
217
209
  showConflictModal = true;
218
210
  } else {
@@ -229,14 +221,13 @@
229
221
  }
230
222
 
231
223
  // Execute restore after conflict resolution
232
- async function confirmConflictRestore() {
224
+ async function confirmConflictRestore(resolutions: ConflictResolution) {
233
225
  if (!conflictCheckingNode) return;
234
226
 
235
- showConflictModal = false;
236
227
  const node = conflictCheckingNode;
237
228
  conflictCheckingNode = null;
238
229
 
239
- await executeRestore(node, conflictResolutions);
230
+ await executeRestore(node, resolutions);
240
231
  }
241
232
 
242
233
  // Execute restore after simple confirmation
@@ -335,35 +326,6 @@
335
326
  return text.substring(0, maxLength) + '...';
336
327
  }
337
328
 
338
- function toggleDiff(filepath: string) {
339
- const next = new Set(expandedDiffs);
340
- if (next.has(filepath)) {
341
- next.delete(filepath);
342
- } else {
343
- next.add(filepath);
344
- }
345
- expandedDiffs = next;
346
- }
347
-
348
- function formatFilePath(filepath: string): string {
349
- const parts = filepath.split('/');
350
- if (parts.length <= 2) return filepath;
351
- return '.../' + parts.slice(-2).join('/');
352
- }
353
-
354
- function formatTimestamp(iso: string): string {
355
- try {
356
- const date = new Date(iso);
357
- return date.toLocaleString(undefined, {
358
- month: 'short',
359
- day: 'numeric',
360
- hour: '2-digit',
361
- minute: '2-digit'
362
- });
363
- } catch {
364
- return iso;
365
- }
366
- }
367
329
  </script>
368
330
 
369
331
  <Modal bind:isOpen={isOpen} bind:contentRef={scrollContainer} size="lg" onClose={onClose}>
@@ -472,129 +434,12 @@
472
434
  />
473
435
 
474
436
  <!-- Conflict Resolution Modal -->
475
- <Modal
437
+ <ConflictResolutionModal
476
438
  bind:isOpen={showConflictModal}
477
- size="md"
439
+ conflicts={conflictList}
440
+ onConfirm={confirmConflictRestore}
478
441
  onClose={() => {
479
- showConflictModal = false;
480
442
  conflictCheckingNode = null;
481
443
  conflictList = [];
482
- conflictResolutions = {};
483
- expandedDiffs = new Set();
484
444
  }}
485
- >
486
- {#snippet header()}
487
- <div class="flex items-start gap-4 px-4 py-3 md:px-6 md:py-4">
488
- <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/50 rounded-xl p-3">
489
- <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
490
- </div>
491
- <div class="flex-1">
492
- <h3 id="modal-title" class="text-lg font-semibold text-slate-900 dark:text-slate-100">
493
- Restore Conflict Detected
494
- </h3>
495
- <p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
496
- The following files were also modified in other sessions. Choose how to handle each file:
497
- </p>
498
- </div>
499
- </div>
500
- {/snippet}
501
-
502
- {#snippet children()}
503
- <div class="space-y-3">
504
- {#each conflictList as conflict}
505
- <div class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
506
- <div class="flex items-center justify-between gap-2 mb-2">
507
- <div class="flex items-center gap-2 min-w-0">
508
- <Icon name="lucide:file-warning" class="w-4 h-4 text-amber-500 shrink-0" />
509
- <span class="text-sm font-medium text-slate-800 dark:text-slate-200 truncate" title={conflict.filepath}>
510
- {formatFilePath(conflict.filepath)}
511
- </span>
512
- </div>
513
- {#if conflict.restoreContent && conflict.currentContent}
514
- <button
515
- class="shrink-0 flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md transition-colors
516
- {expandedDiffs.has(conflict.filepath)
517
- ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-300 dark:border-blue-600'
518
- : 'bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
519
- }"
520
- onclick={() => toggleDiff(conflict.filepath)}
521
- >
522
- <Icon name="lucide:git-compare" class="w-3 h-3" />
523
- {expandedDiffs.has(conflict.filepath) ? 'Hide Diff' : 'View Diff'}
524
- </button>
525
- {/if}
526
- </div>
527
- <p class="text-xs text-slate-500 dark:text-slate-400 mb-2">
528
- Modified by another session on {formatTimestamp(conflict.modifiedAt)}
529
- </p>
530
-
531
- <!-- Diff View -->
532
- {#if expandedDiffs.has(conflict.filepath) && conflict.restoreContent && conflict.currentContent}
533
- <div class="mb-3">
534
- <div class="flex items-center gap-3 mb-1.5 text-3xs text-slate-500 dark:text-slate-400">
535
- <span class="flex items-center gap-1">
536
- <span class="inline-block w-2 h-2 rounded-sm bg-red-400"></span>
537
- Restore version
538
- </span>
539
- <span class="flex items-center gap-1">
540
- <span class="inline-block w-2 h-2 rounded-sm bg-green-400"></span>
541
- Current version
542
- </span>
543
- </div>
544
- <DiffBlock
545
- oldString={conflict.restoreContent}
546
- newString={conflict.currentContent}
547
- />
548
- </div>
549
- {/if}
550
-
551
- <div class="flex gap-2">
552
- <button
553
- class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
554
- onclick={() => { conflictResolutions[conflict.filepath] = 'restore'; }}
555
- >
556
- {#if conflictResolutions[conflict.filepath] === 'restore'}
557
- <span class="absolute -top-1.5 -right-1.5 w-5 h-5 flex items-center justify-center rounded-full bg-green-500 dark:bg-green-600 border-2 border-white dark:border-slate-900">
558
- <Icon name="lucide:check" class="w-3 h-3 text-white" />
559
- </span>
560
- {/if}
561
- Restore
562
- </button>
563
- <button
564
- class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
565
- onclick={() => { conflictResolutions[conflict.filepath] = 'keep'; }}
566
- >
567
- {#if conflictResolutions[conflict.filepath] === 'keep'}
568
- <span class="absolute -top-1.5 -right-1.5 w-5 h-5 flex items-center justify-center rounded-full bg-green-500 dark:bg-green-600 border-2 border-white dark:border-slate-900">
569
- <Icon name="lucide:check" class="w-3 h-3 text-white" />
570
- </span>
571
- {/if}
572
- Keep Current
573
- </button>
574
- </div>
575
- </div>
576
- {/each}
577
- </div>
578
- {/snippet}
579
-
580
- {#snippet footer()}
581
- <button
582
- onclick={() => {
583
- showConflictModal = false;
584
- conflictCheckingNode = null;
585
- conflictList = [];
586
- conflictResolutions = {};
587
- expandedDiffs = new Set();
588
- }}
589
- class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
590
- >
591
- Cancel
592
- </button>
593
- <button
594
- onclick={confirmConflictRestore}
595
- class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg transition-all duration-200 font-semibold"
596
- >
597
- Proceed with Restore
598
- </button>
599
- {/snippet}
600
- </Modal>
445
+ />
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import Dialog from '../overlay/Dialog.svelte';
3
+ import Icon from '../display/Icon.svelte';
4
+ import { updateState, hideRestartModal } from '$frontend/stores/ui/update.svelte';
5
+
6
+ function handleClose() {
7
+ hideRestartModal();
8
+ }
9
+ </script>
10
+
11
+ <Dialog
12
+ bind:isOpen={updateState.showRestartModal}
13
+ onClose={handleClose}
14
+ title="Updated to v{updateState.latestVersion}"
15
+ type="success"
16
+ confirmText="Got it"
17
+ showCancel={false}
18
+ >
19
+ <div class="flex items-start space-x-4">
20
+ <div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700/50 rounded-xl p-3">
21
+ <Icon name="lucide:circle-check" class="w-6 h-6 text-green-600 dark:text-green-400" />
22
+ </div>
23
+
24
+ <div class="flex-1 space-y-3">
25
+ <h3 class="text-lg font-semibold text-green-900 dark:text-green-100">
26
+ Updated to v{updateState.latestVersion}
27
+ </h3>
28
+
29
+ <p class="text-sm text-slate-600 dark:text-slate-400">
30
+ To apply the update, restart the server:
31
+ </p>
32
+
33
+ <ol class="text-sm text-slate-700 dark:text-slate-300 space-y-2.5 list-none pl-0">
34
+ <li class="flex items-start gap-2.5">
35
+ <span class="flex items-center justify-center w-5 h-5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 text-xs font-bold shrink-0 mt-0.5">1</span>
36
+ <span>Go to the terminal where you ran <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs font-mono">clopen</code> <span class="text-slate-500 dark:text-slate-500">(not the terminal inside Clopen)</span></span>
37
+ </li>
38
+ <li class="flex items-start gap-2.5">
39
+ <span class="flex items-center justify-center w-5 h-5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 text-xs font-bold shrink-0 mt-0.5">2</span>
40
+ <span>Press <kbd class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded text-xs font-mono">Ctrl+C</kbd> to stop the server</span>
41
+ </li>
42
+ <li class="flex items-start gap-2.5">
43
+ <span class="flex items-center justify-center w-5 h-5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 text-xs font-bold shrink-0 mt-0.5">3</span>
44
+ <span>Run <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs font-mono">clopen</code> again</span>
45
+ </li>
46
+ <li class="flex items-start gap-2.5">
47
+ <span class="flex items-center justify-center w-5 h-5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 text-xs font-bold shrink-0 mt-0.5">4</span>
48
+ <span>Refresh this browser tab</span>
49
+ </li>
50
+ </ol>
51
+ </div>
52
+ </div>
53
+ </Dialog>
@@ -1,16 +1,17 @@
1
1
  <script lang="ts">
2
- import { updateState, runUpdate, dismissUpdate, checkForUpdate } from '$frontend/stores/ui/update.svelte';
2
+ import { updateState, runUpdate, dismissUpdate, checkForUpdate, showRestartModal } from '$frontend/stores/ui/update.svelte';
3
3
  import { systemSettings, updateSystemSettings } from '$frontend/stores/features/settings.svelte';
4
4
  import Icon from '$frontend/components/common/display/Icon.svelte';
5
5
  import { slide } from 'svelte/transition';
6
6
 
7
7
  const showBanner = $derived(
8
- !updateState.dismissed && (
8
+ updateState.pendingRestart ||
9
+ (!updateState.dismissed && (
9
10
  updateState.updateAvailable ||
10
11
  updateState.updating ||
11
12
  updateState.updateSuccess ||
12
13
  updateState.error
13
- )
14
+ ))
14
15
  );
15
16
 
16
17
  function handleUpdate() {
@@ -28,13 +29,17 @@
28
29
  function handleRetry() {
29
30
  checkForUpdate();
30
31
  }
32
+
33
+ function handleShowRestart() {
34
+ showRestartModal();
35
+ }
31
36
  </script>
32
37
 
33
38
  {#if showBanner}
34
39
  <div
35
40
  transition:slide={{ duration: 300 }}
36
41
  class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
37
- {updateState.updateSuccess
42
+ {updateState.updateSuccess || updateState.pendingRestart
38
43
  ? 'bg-emerald-600 text-white'
39
44
  : updateState.error
40
45
  ? 'bg-red-600 text-white'
@@ -44,9 +49,15 @@
44
49
  role="status"
45
50
  aria-live="polite"
46
51
  >
47
- {#if updateState.updateSuccess}
52
+ {#if updateState.updateSuccess || updateState.pendingRestart}
48
53
  <Icon name="lucide:package-check" class="w-4 h-4" />
49
- <span>Updated to v{updateState.latestVersion} — restart clopen to apply</span>
54
+ <span>Updated to v{updateState.latestVersion} — restart required</span>
55
+ <button
56
+ onclick={handleShowRestart}
57
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
58
+ >
59
+ How to restart
60
+ </button>
50
61
  {:else if updateState.error}
51
62
  <Icon name="lucide:package-x" class="w-4 h-4" />
52
63
  <span>{updateState.errorType === 'check' ? 'Unable to check for updates' : 'Update failed'}</span>
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ import { onDestroy, untrack } from 'svelte';
3
+ import Icon from '$frontend/components/common/display/Icon.svelte';
4
+ import LoadingSpinner from '$frontend/components/common/feedback/LoadingSpinner.svelte';
5
+ import { isImageFile, isSvgFile, isPdfFile, isAudioFile, isVideoFile } from '$frontend/utils/file-type';
6
+ import { debug } from '$shared/utils/logger';
7
+ import ws from '$frontend/utils/ws';
8
+
9
+ interface Props {
10
+ /** File name for type detection */
11
+ fileName: string;
12
+ /** Absolute path used to load binary content via ws */
13
+ filePath: string;
14
+ /** Optional text content for SVG inline rendering */
15
+ svgContent?: string;
16
+ }
17
+
18
+ const { fileName, filePath, svgContent }: Props = $props();
19
+
20
+ let blobUrl = $state<string | null>(null);
21
+ let pdfBlobUrl = $state<string | null>(null);
22
+ let mediaBlobUrl = $state<string | null>(null);
23
+ let isLoading = $state(false);
24
+
25
+ function cleanup() {
26
+ if (blobUrl) {
27
+ URL.revokeObjectURL(blobUrl);
28
+ blobUrl = null;
29
+ }
30
+ if (pdfBlobUrl) {
31
+ URL.revokeObjectURL(pdfBlobUrl);
32
+ pdfBlobUrl = null;
33
+ }
34
+ if (mediaBlobUrl) {
35
+ URL.revokeObjectURL(mediaBlobUrl);
36
+ mediaBlobUrl = null;
37
+ }
38
+ }
39
+
40
+ async function loadBinaryContent(path: string, name: string) {
41
+ isLoading = true;
42
+ try {
43
+ const response = await ws.http('files:read-content', { path });
44
+
45
+ if (response.content) {
46
+ const binaryString = atob(response.content);
47
+ const bytes = new Uint8Array(binaryString.length);
48
+ for (let i = 0; i < binaryString.length; i++) {
49
+ bytes[i] = binaryString.charCodeAt(i);
50
+ }
51
+ const blob = new Blob([bytes], { type: response.contentType || 'application/octet-stream' });
52
+
53
+ if (isPdfFile(name)) {
54
+ if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
55
+ pdfBlobUrl = URL.createObjectURL(blob);
56
+ } else if (isAudioFile(name) || isVideoFile(name)) {
57
+ if (mediaBlobUrl) URL.revokeObjectURL(mediaBlobUrl);
58
+ mediaBlobUrl = URL.createObjectURL(blob);
59
+ } else {
60
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
61
+ blobUrl = URL.createObjectURL(blob);
62
+ }
63
+ }
64
+ } catch (err) {
65
+ debug.error('file', 'Failed to load binary content:', err);
66
+ } finally {
67
+ isLoading = false;
68
+ }
69
+ }
70
+
71
+ $effect(() => {
72
+ const name = fileName;
73
+ const path = filePath;
74
+ untrack(() => {
75
+ cleanup();
76
+ if (name && path) {
77
+ loadBinaryContent(path, name);
78
+ }
79
+ });
80
+ });
81
+
82
+ onDestroy(cleanup);
83
+ </script>
84
+
85
+ {#if isImageFile(fileName)}
86
+ <div class="flex items-center justify-center h-full p-4 overflow-hidden checkerboard-bg">
87
+ {#if isLoading}
88
+ <LoadingSpinner size="lg" />
89
+ {:else if blobUrl}
90
+ <img
91
+ src={blobUrl}
92
+ alt={fileName}
93
+ class="max-w-full max-h-full object-contain"
94
+ />
95
+ {:else}
96
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
97
+ <Icon name="lucide:image-off" class="w-8 h-8 opacity-40" />
98
+ <span>Failed to load preview</span>
99
+ </div>
100
+ {/if}
101
+ </div>
102
+ {:else if isSvgFile(fileName)}
103
+ <div class="flex items-center justify-center h-full p-4 overflow-auto checkerboard-bg">
104
+ {#if isLoading}
105
+ <LoadingSpinner size="lg" />
106
+ {:else if blobUrl}
107
+ <img
108
+ src={blobUrl}
109
+ alt={fileName}
110
+ class="max-w-full max-h-full object-contain"
111
+ />
112
+ {:else if svgContent}
113
+ <div class="max-w-full max-h-full flex items-center justify-center">
114
+ {@html svgContent}
115
+ </div>
116
+ {:else}
117
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
118
+ <Icon name="lucide:image-off" class="w-8 h-8 opacity-40" />
119
+ <span>Failed to load preview</span>
120
+ </div>
121
+ {/if}
122
+ </div>
123
+ {:else if isPdfFile(fileName)}
124
+ <div class="h-full w-full">
125
+ {#if isLoading}
126
+ <div class="flex items-center justify-center h-full">
127
+ <LoadingSpinner size="lg" />
128
+ </div>
129
+ {:else if pdfBlobUrl}
130
+ <iframe
131
+ src={pdfBlobUrl}
132
+ title={fileName}
133
+ class="w-full h-full border-0"
134
+ ></iframe>
135
+ {:else}
136
+ <div class="flex flex-col items-center justify-center h-full gap-2 text-slate-500 text-xs">
137
+ <Icon name="lucide:file-x" class="w-8 h-8 opacity-40" />
138
+ <span>Failed to load PDF preview</span>
139
+ </div>
140
+ {/if}
141
+ </div>
142
+ {:else if isAudioFile(fileName)}
143
+ <div class="flex flex-col items-center justify-center h-full p-8 checkerboard-bg">
144
+ <Icon name="lucide:music" class="w-16 h-16 text-violet-400 mb-6" />
145
+ <h3 class="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-4">
146
+ {fileName}
147
+ </h3>
148
+ {#if isLoading}
149
+ <LoadingSpinner size="lg" />
150
+ {:else if mediaBlobUrl}
151
+ <audio controls class="w-full max-w-md" src={mediaBlobUrl}>
152
+ Your browser does not support the audio element.
153
+ </audio>
154
+ {:else}
155
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
156
+ <Icon name="lucide:music" class="w-8 h-8 opacity-40" />
157
+ <span>Failed to load audio</span>
158
+ </div>
159
+ {/if}
160
+ </div>
161
+ {:else if isVideoFile(fileName)}
162
+ <div class="flex items-center justify-center h-full p-4 overflow-hidden checkerboard-bg">
163
+ {#if isLoading}
164
+ <LoadingSpinner size="lg" />
165
+ {:else if mediaBlobUrl}
166
+ <!-- svelte-ignore a11y_media_has_caption -->
167
+ <video controls class="max-w-full max-h-full object-contain" src={mediaBlobUrl}>
168
+ Your browser does not support the video element.
169
+ </video>
170
+ {:else}
171
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
172
+ <Icon name="lucide:video-off" class="w-8 h-8 opacity-40" />
173
+ <span>Failed to load video</span>
174
+ </div>
175
+ {/if}
176
+ </div>
177
+ {/if}
178
+
179
+ <style>
180
+ .checkerboard-bg {
181
+ background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%3E%3Crect%20width%3D%2220%22%20height%3D%2220%22%20fill%3D%22%23f0f0f0%22%2F%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%2210%22%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3C%2Fsvg%3E');
182
+ }
183
+
184
+ :global(.dark) .checkerboard-bg {
185
+ background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%3E%3Crect%20width%3D%2220%22%20height%3D%2220%22%20fill%3D%22%23181818%22%2F%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23222222%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%2210%22%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23222222%22%2F%3E%3C%2Fsvg%3E');
186
+ }
187
+ </style>