@myrialabs/clopen 0.2.4 → 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.
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +11 -143
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- 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
|
@@ -356,6 +356,35 @@ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): Normalize
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ============================================================
|
|
360
|
+
// Tool Error Detection
|
|
361
|
+
// ============================================================
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Common error prefixes in tool output content.
|
|
365
|
+
* OpenCode SDK may mark a tool as 'completed' even when the output is an error
|
|
366
|
+
* (e.g. "Error: File not found"). These patterns detect such cases.
|
|
367
|
+
*/
|
|
368
|
+
const ERROR_CONTENT_PATTERNS = [
|
|
369
|
+
/^Error:\s/i,
|
|
370
|
+
/^ENOENT:\s/i,
|
|
371
|
+
/^EPERM:\s/i,
|
|
372
|
+
/^EACCES:\s/i,
|
|
373
|
+
/^Command failed/i,
|
|
374
|
+
/^Permission denied/i,
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Determine if a tool result should be marked as is_error.
|
|
379
|
+
* Returns true when the tool part status is 'error', OR when the output
|
|
380
|
+
* content matches a known error pattern (for tools that complete with error output).
|
|
381
|
+
*/
|
|
382
|
+
function isToolError(status: string, content: string): boolean {
|
|
383
|
+
if (status === 'error') return true;
|
|
384
|
+
if (!content || status !== 'completed') return false;
|
|
385
|
+
return ERROR_CONTENT_PATTERNS.some(pattern => pattern.test(content));
|
|
386
|
+
}
|
|
387
|
+
|
|
359
388
|
// ============================================================
|
|
360
389
|
// Stop Reason Mapping
|
|
361
390
|
// ============================================================
|
|
@@ -483,16 +512,19 @@ export function convertAssistantMessages(
|
|
|
483
512
|
};
|
|
484
513
|
|
|
485
514
|
if (toolPart.state.status === 'completed') {
|
|
515
|
+
const output = toolPart.state.output || '';
|
|
486
516
|
block.$result = {
|
|
487
517
|
type: 'tool_result',
|
|
488
518
|
tool_use_id: block.id,
|
|
489
|
-
content:
|
|
519
|
+
content: output,
|
|
520
|
+
...(isToolError('completed', output) && { is_error: true }),
|
|
490
521
|
};
|
|
491
522
|
} else if (toolPart.state.status === 'error') {
|
|
492
523
|
block.$result = {
|
|
493
524
|
type: 'tool_result',
|
|
494
525
|
tool_use_id: block.id,
|
|
495
526
|
content: toolPart.state.error || 'Tool execution failed',
|
|
527
|
+
is_error: true,
|
|
496
528
|
};
|
|
497
529
|
}
|
|
498
530
|
|
|
@@ -854,6 +886,8 @@ export function convertToolResultOnly(
|
|
|
854
886
|
content = '';
|
|
855
887
|
}
|
|
856
888
|
|
|
889
|
+
const hasError = isToolError(toolPart.state.status, content);
|
|
890
|
+
|
|
857
891
|
return {
|
|
858
892
|
type: 'user',
|
|
859
893
|
uuid: crypto.randomUUID(),
|
|
@@ -864,7 +898,8 @@ export function convertToolResultOnly(
|
|
|
864
898
|
content: [{
|
|
865
899
|
type: 'tool_result',
|
|
866
900
|
tool_use_id: toolUseId,
|
|
867
|
-
content
|
|
901
|
+
content,
|
|
902
|
+
...(hasError && { is_error: true }),
|
|
868
903
|
}]
|
|
869
904
|
}
|
|
870
905
|
} as unknown as SDKMessage;
|
package/backend/ws/user/crud.ts
CHANGED
|
@@ -109,7 +109,8 @@ export const crudHandler = createRouter()
|
|
|
109
109
|
currentProjectId: t.Union([t.String(), t.Null()]),
|
|
110
110
|
lastView: t.Union([t.String(), t.Null()]),
|
|
111
111
|
settings: t.Union([t.Any(), t.Null()]),
|
|
112
|
-
unreadSessions: t.Union([t.Any(), t.Null()])
|
|
112
|
+
unreadSessions: t.Union([t.Any(), t.Null()]),
|
|
113
|
+
todoPanelState: t.Union([t.Any(), t.Null()])
|
|
113
114
|
})
|
|
114
115
|
}, async ({ conn }) => {
|
|
115
116
|
const userId = ws.getUserId(conn);
|
|
@@ -118,6 +119,7 @@ export const crudHandler = createRouter()
|
|
|
118
119
|
const lastView = getUserState(userId, 'lastView') as string | null;
|
|
119
120
|
const userSettings = getUserState(userId, 'settings');
|
|
120
121
|
const unreadSessions = getUserState(userId, 'unreadSessions');
|
|
122
|
+
const todoPanelState = getUserState(userId, 'todoPanelState');
|
|
121
123
|
|
|
122
124
|
debug.log('user', `Restored state for ${userId}:`, {
|
|
123
125
|
currentProjectId,
|
|
@@ -130,7 +132,8 @@ export const crudHandler = createRouter()
|
|
|
130
132
|
currentProjectId: currentProjectId ?? null,
|
|
131
133
|
lastView: lastView ?? null,
|
|
132
134
|
settings: userSettings ?? null,
|
|
133
|
-
unreadSessions: unreadSessions ?? null
|
|
135
|
+
unreadSessions: unreadSessions ?? null,
|
|
136
|
+
todoPanelState: todoPanelState ?? null
|
|
134
137
|
};
|
|
135
138
|
})
|
|
136
139
|
|
|
@@ -147,7 +150,7 @@ export const crudHandler = createRouter()
|
|
|
147
150
|
const userId = ws.getUserId(conn);
|
|
148
151
|
|
|
149
152
|
// Validate allowed keys to prevent arbitrary data storage
|
|
150
|
-
const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions'];
|
|
153
|
+
const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions', 'todoPanelState'];
|
|
151
154
|
if (!allowedKeys.includes(data.key)) {
|
|
152
155
|
throw new Error(`Invalid state key: ${data.key}. Allowed: ${allowedKeys.join(', ')}`);
|
|
153
156
|
}
|
|
@@ -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">
|
|
@@ -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>
|