@myrialabs/clopen 0.2.4 → 0.2.6
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.
- package/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +21 -3
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +23 -144
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/utils/file-type.ts +68 -0
- package/index.html +1 -0
- package/package.json +1 -1
- package/shared/constants/binary-extensions.ts +40 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
-->
|
|
10
10
|
|
|
11
11
|
<script lang="ts">
|
|
12
|
+
import { tick } from 'svelte';
|
|
12
13
|
import type { SDKMessageFormatter } from '$shared/types/database/schema';
|
|
13
14
|
import type { IconName } from '$shared/types/ui/icons';
|
|
14
15
|
import Card from '$frontend/components/common/display/Card.svelte';
|
|
@@ -46,6 +47,23 @@
|
|
|
46
47
|
onShowTokenUsage: () => void;
|
|
47
48
|
onShowDebug: () => void;
|
|
48
49
|
} = $props();
|
|
50
|
+
|
|
51
|
+
let scrollContainer: HTMLDivElement | undefined = $state();
|
|
52
|
+
|
|
53
|
+
// Auto-scroll reasoning/system content to bottom while receiving partial text
|
|
54
|
+
$effect(() => {
|
|
55
|
+
if (roleCategory !== 'reasoning' && roleCategory !== 'system') return;
|
|
56
|
+
if (!scrollContainer) return;
|
|
57
|
+
// Track message content changes (partialText for streaming, message for final)
|
|
58
|
+
const _track = message.type === 'stream_event' && 'partialText' in message
|
|
59
|
+
? message.partialText
|
|
60
|
+
: message;
|
|
61
|
+
tick().then(() => {
|
|
62
|
+
if (scrollContainer) {
|
|
63
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
49
67
|
</script>
|
|
50
68
|
|
|
51
69
|
<div class="relative overflow-hidden">
|
|
@@ -73,7 +91,10 @@
|
|
|
73
91
|
/>
|
|
74
92
|
|
|
75
93
|
<!-- Message Content -->
|
|
76
|
-
<div
|
|
94
|
+
<div
|
|
95
|
+
bind:this={scrollContainer}
|
|
96
|
+
class="p-3 md:p-4 {roleCategory === 'reasoning' || roleCategory === 'system' ? 'max-h-80 overflow-y-auto' : ''}"
|
|
97
|
+
>
|
|
77
98
|
<div class="max-w-none space-y-4">
|
|
78
99
|
<!-- Content rendering using MessageFormatter component -->
|
|
79
100
|
<MessageFormatter {message} />
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
import type { IconName } from '$shared/types/ui/icons';
|
|
4
4
|
import { getFileIcon } from '$frontend/utils/file-icon-mappings';
|
|
5
5
|
import { formatPath } from '../../shared/utils';
|
|
6
|
+
import { requestRevealFile } from '$frontend/stores/core/files.svelte';
|
|
7
|
+
import { getVisiblePanels, workspaceState } from '$frontend/stores/ui/workspace.svelte';
|
|
8
|
+
|
|
9
|
+
function handleClick() {
|
|
10
|
+
const visiblePanels = getVisiblePanels(workspaceState.layout);
|
|
11
|
+
if (visiblePanels.includes('files')) {
|
|
12
|
+
requestRevealFile(filePath);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
interface Props {
|
|
8
17
|
filePath: string;
|
|
@@ -18,10 +27,15 @@
|
|
|
18
27
|
</script>
|
|
19
28
|
|
|
20
29
|
<div class={box ? "bg-white dark:bg-slate-800 rounded-md border border-slate-200/60 dark:border-slate-700/60 p-3" : ""}>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="flex items-center gap-3 mb-1 w-full text-left hover:opacity-80 transition-opacity cursor-pointer"
|
|
33
|
+
onclick={handleClick}
|
|
34
|
+
title="Reveal in Files panel"
|
|
35
|
+
>
|
|
36
|
+
<Icon
|
|
37
|
+
name={getFileIcon(displayFileName)}
|
|
38
|
+
class="w-6 h-6 {iconColor || ''}"
|
|
25
39
|
/>
|
|
26
40
|
<div class="flex-1 min-w-0">
|
|
27
41
|
<h3 class="font-medium text-slate-900 dark:text-slate-100 truncate">
|
|
@@ -31,7 +45,7 @@
|
|
|
31
45
|
{formatPath(filePath)}
|
|
32
46
|
</p>
|
|
33
47
|
</div>
|
|
34
|
-
</
|
|
48
|
+
</button>
|
|
35
49
|
|
|
36
50
|
{#if badges.length > 0}
|
|
37
51
|
<div class="flex gap-2 mt-3">
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
<span class="text-xs font-medium text-slate-700 dark:text-slate-300">Command:</span>
|
|
34
34
|
</div>
|
|
35
35
|
{#if timeout}
|
|
36
|
-
<div class="inline-block ml-auto text-
|
|
36
|
+
<div class="inline-block ml-auto text-3xs bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 px-2 py-0.5 rounded">
|
|
37
37
|
Timeout: {timeout}ms
|
|
38
38
|
</div>
|
|
39
39
|
{/if}
|
|
40
40
|
</div>
|
|
41
41
|
|
|
42
42
|
<!-- Terminal-style command display -->
|
|
43
|
-
<div class="bg-slate-50 dark:bg-slate-950 border border-slate-200/60 dark:border-slate-800/60 rounded-md p-2.5 font-mono text-sm">
|
|
43
|
+
<div class="max-h-64 overflow-y-auto bg-slate-50 dark:bg-slate-950 border border-slate-200/60 dark:border-slate-800/60 rounded-md p-2.5 font-mono text-sm">
|
|
44
44
|
<div class="flex items-start gap-2">
|
|
45
45
|
<span class="text-green-600 dark:text-green-400 select-none">$</span>
|
|
46
46
|
<div class="flex-1 text-slate-900 dark:text-slate-200 break-all">
|
|
@@ -8,17 +8,14 @@
|
|
|
8
8
|
<script lang="ts">
|
|
9
9
|
import { sessionState } from '$frontend/stores/core/sessions.svelte';
|
|
10
10
|
import { appState } from '$frontend/stores/core/app.svelte';
|
|
11
|
+
import { todoPanelState, saveTodoPanelState } from '$frontend/stores/ui/todo-panel.svelte';
|
|
11
12
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
12
13
|
import { fly } from 'svelte/transition';
|
|
13
14
|
import type { TodoWriteToolInput } from '$shared/types/messaging';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
// Drag & snap state
|
|
19
|
-
let posY = $state(80);
|
|
16
|
+
// Drag-only local state (posX is always transient, posY syncs to store on drop)
|
|
17
|
+
let posY = $state(todoPanelState.posY);
|
|
20
18
|
let posX = $state(0);
|
|
21
|
-
let snapSide = $state<'left' | 'right'>('right');
|
|
22
19
|
let isDragging = $state(false);
|
|
23
20
|
|
|
24
21
|
// Minimized button ref for measuring width at snap time
|
|
@@ -28,18 +25,18 @@
|
|
|
28
25
|
let _sx = 0, _sy = 0, _mx = 0, _my = 0, _hasDragged = false;
|
|
29
26
|
|
|
30
27
|
function getPanelWidth() {
|
|
31
|
-
return isExpanded ? 330 : 230;
|
|
28
|
+
return todoPanelState.isExpanded ? 330 : 230;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
// Always use `left` property so CSS can transition in both directions
|
|
35
32
|
const panelDisplayLeft = $derived(
|
|
36
|
-
isDragging ? posX : snapSide === 'right' ? window.innerWidth - getPanelWidth() - 16 : 16
|
|
33
|
+
isDragging ? posX : todoPanelState.snapSide === 'right' ? window.innerWidth - getPanelWidth() - 16 : 16
|
|
37
34
|
);
|
|
38
35
|
|
|
39
36
|
const minimizedDisplayLeft = $derived(
|
|
40
37
|
isDragging
|
|
41
38
|
? posX
|
|
42
|
-
: snapSide === 'right'
|
|
39
|
+
: todoPanelState.snapSide === 'right'
|
|
43
40
|
? window.innerWidth - (minimizedBtn?.offsetWidth ?? 90) - 16
|
|
44
41
|
: 16
|
|
45
42
|
);
|
|
@@ -69,7 +66,9 @@
|
|
|
69
66
|
function endDrag(e: PointerEvent) {
|
|
70
67
|
if (!isDragging) return;
|
|
71
68
|
isDragging = false;
|
|
72
|
-
snapSide = posX + getPanelWidth() / 2 < window.innerWidth / 2 ? 'left' : 'right';
|
|
69
|
+
todoPanelState.snapSide = posX + getPanelWidth() / 2 < window.innerWidth / 2 ? 'left' : 'right';
|
|
70
|
+
todoPanelState.posY = posY;
|
|
71
|
+
saveTodoPanelState();
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
// --- Minimized button drag (click = restore, drag = move) ---
|
|
@@ -103,7 +102,9 @@
|
|
|
103
102
|
return;
|
|
104
103
|
}
|
|
105
104
|
const el = e.currentTarget as HTMLElement;
|
|
106
|
-
snapSide = posX + el.offsetWidth / 2 < window.innerWidth / 2 ? 'left' : 'right';
|
|
105
|
+
todoPanelState.snapSide = posX + el.offsetWidth / 2 < window.innerWidth / 2 ? 'left' : 'right';
|
|
106
|
+
todoPanelState.posY = posY;
|
|
107
|
+
saveTodoPanelState();
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
// Extract the latest TodoWrite data from messages
|
|
@@ -151,19 +152,22 @@
|
|
|
151
152
|
const shouldShow = $derived(latestTodos !== null && latestTodos.length > 0);
|
|
152
153
|
|
|
153
154
|
function toggleExpand() {
|
|
154
|
-
if (!isMinimized) {
|
|
155
|
-
isExpanded = !isExpanded;
|
|
155
|
+
if (!todoPanelState.isMinimized) {
|
|
156
|
+
todoPanelState.isExpanded = !todoPanelState.isExpanded;
|
|
157
|
+
saveTodoPanelState();
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
function minimize() {
|
|
160
|
-
isMinimized = true;
|
|
161
|
-
isExpanded = false;
|
|
162
|
+
todoPanelState.isMinimized = true;
|
|
163
|
+
todoPanelState.isExpanded = false;
|
|
164
|
+
saveTodoPanelState();
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
function restore() {
|
|
165
|
-
isMinimized = false;
|
|
166
|
-
isExpanded = true;
|
|
168
|
+
todoPanelState.isMinimized = false;
|
|
169
|
+
todoPanelState.isExpanded = true;
|
|
170
|
+
saveTodoPanelState();
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
function getStatusIcon(status: string) {
|
|
@@ -194,7 +198,7 @@
|
|
|
194
198
|
</script>
|
|
195
199
|
|
|
196
200
|
{#if shouldShow && !appState.isRestoring}
|
|
197
|
-
{#if isMinimized}
|
|
201
|
+
{#if todoPanelState.isMinimized}
|
|
198
202
|
<!-- Minimized state - small floating button, draggable -->
|
|
199
203
|
<button
|
|
200
204
|
bind:this={minimizedBtn}
|
|
@@ -210,7 +214,7 @@
|
|
|
210
214
|
cursor: {isDragging ? 'grabbing' : 'grab'};
|
|
211
215
|
transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease'};
|
|
212
216
|
"
|
|
213
|
-
transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 200 }}
|
|
217
|
+
transition:fly={{ x: todoPanelState.snapSide === 'right' ? 100 : -100, duration: 200 }}
|
|
214
218
|
>
|
|
215
219
|
<Icon name="lucide:list-todo" class="w-5 h-5" />
|
|
216
220
|
<span class="text-sm font-medium">{progress.completed}/{progress.total}</span>
|
|
@@ -222,11 +226,11 @@
|
|
|
222
226
|
style="
|
|
223
227
|
top: {posY}px;
|
|
224
228
|
left: {panelDisplayLeft}px;
|
|
225
|
-
width: {isExpanded ? '330px' : '230px'};
|
|
226
|
-
max-height: {isExpanded ? '600px' : '56px'};
|
|
229
|
+
width: {todoPanelState.isExpanded ? '330px' : '230px'};
|
|
230
|
+
max-height: {todoPanelState.isExpanded ? '600px' : '56px'};
|
|
227
231
|
transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease, width 0.3s, max-height 0.3s'};
|
|
228
232
|
"
|
|
229
|
-
transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 300 }}
|
|
233
|
+
transition:fly={{ x: todoPanelState.snapSide === 'right' ? 100 : -100, duration: 300 }}
|
|
230
234
|
>
|
|
231
235
|
<!-- Header (drag handle) -->
|
|
232
236
|
<div
|
|
@@ -244,7 +248,7 @@
|
|
|
244
248
|
<span class="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
245
249
|
Task Progress
|
|
246
250
|
</span>
|
|
247
|
-
{#if !isExpanded}
|
|
251
|
+
{#if !todoPanelState.isExpanded}
|
|
248
252
|
<span class="text-xs text-slate-600 dark:text-slate-400">
|
|
249
253
|
{progress.completed}/{progress.total} tasks ({progress.percentage}%)
|
|
250
254
|
</span>
|
|
@@ -256,10 +260,10 @@
|
|
|
256
260
|
<button
|
|
257
261
|
onclick={toggleExpand}
|
|
258
262
|
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
|
259
|
-
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
263
|
+
title={todoPanelState.isExpanded ? 'Collapse' : 'Expand'}
|
|
260
264
|
>
|
|
261
265
|
<Icon
|
|
262
|
-
name={isExpanded ? 'lucide:chevron-up' : 'lucide:chevron-down'}
|
|
266
|
+
name={todoPanelState.isExpanded ? 'lucide:chevron-up' : 'lucide:chevron-down'}
|
|
263
267
|
class="w-4 h-4 text-slate-600 dark:text-slate-400"
|
|
264
268
|
/>
|
|
265
269
|
</button>
|
|
@@ -273,7 +277,7 @@
|
|
|
273
277
|
</div>
|
|
274
278
|
</div>
|
|
275
279
|
|
|
276
|
-
{#if isExpanded}
|
|
280
|
+
{#if todoPanelState.isExpanded}
|
|
277
281
|
<!-- Progress bar -->
|
|
278
282
|
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-800">
|
|
279
283
|
<div class="flex items-center justify-between mb-2">
|
|
@@ -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>
|