Skip to content

Commit 932f31e

Browse files
committed
feat: Enables previewing attachments in a dialog
Adds a dialog for previewing attachments, including images, text, PDFs, and audio files. This change enhances the user experience by allowing users to view attachments in a larger, more detailed format before downloading or interacting with them. It introduces a new component, `ChatAttachmentPreviewDialog.svelte`, and updates the `ChatAttachmentsList.svelte` and `ChatAttachmentImagePreview.svelte` components to trigger the dialog.
1 parent ecf1f04 commit 932f31e

File tree

3 files changed

+221
-12
lines changed

3 files changed

+221
-12
lines changed

tools/server/webui/src/lib/components/chat/ChatAttachmentImagePreview.svelte

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
size?: number;
1010
readonly?: boolean;
1111
onRemove?: (id: string) => void;
12+
onClick?: () => void;
1213
class?: string;
1314
// Customizable size props
1415
width?: string;
@@ -23,24 +24,36 @@
2324
size,
2425
readonly = false,
2526
onRemove,
27+
onClick,
2628
class: className = '',
2729
// Default to small size for form previews
2830
width = 'w-auto',
2931
height = 'h-24',
3032
imageClass = ''
3133
}: Props = $props();
32-
33-
function formatFileSize(bytes: number): string {
34-
if (bytes === 0) return '0 Bytes';
35-
const k = 1024;
36-
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
37-
const i = Math.floor(Math.log(bytes) / Math.log(k));
38-
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
39-
}
4034
</script>
4135

4236
<div class="bg-muted border-border relative overflow-hidden rounded-lg border {className}">
43-
<img src={preview} alt={name} class="{height} {width} object-cover {imageClass}" />
37+
{#if onClick}
38+
<button
39+
type="button"
40+
class="focus:ring-primary block h-full w-full rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
41+
onclick={onClick}
42+
aria-label="Preview {name}"
43+
>
44+
<img
45+
src={preview}
46+
alt={name}
47+
class="{height} {width} cursor-pointer object-cover {imageClass}"
48+
/>
49+
</button>
50+
{:else}
51+
<img
52+
src={preview}
53+
alt={name}
54+
class="{height} {width} cursor-pointer object-cover {imageClass}"
55+
/>
56+
{/if}
4457
{#if !readonly}
4558
<div
4659
class="absolute right-1 top-1 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100"
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script lang="ts">
2+
import * as Dialog from '$lib/components/ui/dialog';
3+
import { FileText, Image, Music, FileIcon } from '@lucide/svelte';
4+
import type { DatabaseMessageExtra } from '$lib/types/database.d.ts';
5+
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
6+
7+
interface Props {
8+
open: boolean;
9+
// Either an uploaded file or a stored attachment
10+
uploadedFile?: ChatUploadedFile;
11+
attachment?: DatabaseMessageExtra;
12+
// For uploaded files
13+
preview?: string;
14+
name?: string;
15+
type?: string;
16+
size?: number;
17+
textContent?: string;
18+
}
19+
20+
let {
21+
open = $bindable(),
22+
uploadedFile,
23+
attachment,
24+
preview,
25+
name,
26+
type,
27+
size,
28+
textContent
29+
}: Props = $props();
30+
31+
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
32+
33+
let displayType = $derived(
34+
uploadedFile?.type ||
35+
(attachment?.type === 'imageFile'
36+
? 'image'
37+
: attachment?.type === 'textFile'
38+
? 'text'
39+
: attachment?.type === 'audioFile'
40+
? (attachment as any).mimeType || 'audio'
41+
: attachment?.type === 'pdfFile'
42+
? 'application/pdf'
43+
: type || 'unknown')
44+
);
45+
46+
let displaySize = $derived(uploadedFile?.size || size);
47+
48+
let displayPreview = $derived(
49+
uploadedFile?.preview ||
50+
(attachment?.type === 'imageFile' ? (attachment as any).base64Url : preview)
51+
);
52+
53+
let displayTextContent = $derived(
54+
uploadedFile?.textContent ||
55+
(attachment?.type === 'textFile'
56+
? (attachment as any).content
57+
: attachment?.type === 'pdfFile'
58+
? (attachment as any).content
59+
: textContent)
60+
);
61+
62+
let isImage = $derived(displayType.startsWith('image/') || displayType === 'image');
63+
let isText = $derived(displayType.startsWith('text/') || displayType === 'text');
64+
let isPdf = $derived(displayType === 'application/pdf');
65+
let isAudio = $derived(displayType.startsWith('audio/') || displayType === 'audio');
66+
67+
let IconComponent = $derived(() => {
68+
if (isImage) return Image;
69+
if (isText || isPdf) return FileText;
70+
if (isAudio) return Music;
71+
return FileIcon;
72+
});
73+
74+
function formatFileSize(bytes: number): string {
75+
if (bytes === 0) return '0 Bytes';
76+
77+
const k = 1024;
78+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
79+
const i = Math.floor(Math.log(bytes) / Math.log(k));
80+
81+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
82+
}
83+
</script>
84+
85+
<Dialog.Root bind:open>
86+
<Dialog.Content class="grid max-h-[90vh] max-w-4xl overflow-hidden sm:w-auto sm:max-w-6xl">
87+
<Dialog.Header class="flex-shrink-0">
88+
<div class="flex items-center space-x-4">
89+
<div class="flex items-center gap-3">
90+
{#if IconComponent}
91+
<IconComponent class="text-muted-foreground h-5 w-5" />
92+
{/if}
93+
94+
<div>
95+
<Dialog.Title class="text-left">{displayName}</Dialog.Title>
96+
97+
<div class="text-muted-foreground flex items-center gap-2 text-sm">
98+
<span>{displayType}</span>
99+
{#if displaySize}
100+
<span>•</span>
101+
<span>{formatFileSize(displaySize)}</span>
102+
{/if}
103+
</div>
104+
</div>
105+
</div>
106+
</div>
107+
</Dialog.Header>
108+
109+
<div class="flex-1 overflow-auto">
110+
{#if isImage && displayPreview}
111+
<div class="flex items-center justify-center p-4">
112+
<img
113+
src={displayPreview}
114+
alt={displayName}
115+
class="max-h-full rounded-lg object-contain shadow-lg"
116+
/>
117+
</div>
118+
{:else if (isText || isPdf) && displayTextContent}
119+
<div class="p-4">
120+
<div
121+
class="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-lg p-4 font-mono text-sm"
122+
>
123+
{displayTextContent}
124+
</div>
125+
</div>
126+
{:else if isAudio && attachment?.type === 'audioFile'}
127+
<div class="flex items-center justify-center p-8">
128+
<div class="text-center">
129+
<Music class="text-muted-foreground mx-auto mb-4 h-16 w-16" />
130+
131+
<p class="text-muted-foreground mb-4">Audio file preview not available</p>
132+
</div>
133+
</div>
134+
{:else}
135+
<div class="flex items-center justify-center p-8">
136+
<div class="text-center">
137+
{#if IconComponent}
138+
<IconComponent class="text-muted-foreground mx-auto mb-4 h-16 w-16" />
139+
{/if}
140+
141+
<p class="text-muted-foreground mb-4">
142+
Preview not available for this file type
143+
</p>
144+
</div>
145+
</div>
146+
{/if}
147+
</div>
148+
</Dialog.Content>
149+
</Dialog.Root>

tools/server/webui/src/lib/components/chat/ChatAttachmentsList.svelte

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components';
3+
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
34
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
45
import type { DatabaseMessageExtra } from '$lib/types/database.d.ts';
56
@@ -31,7 +32,35 @@
3132
3233
let displayItems = $derived(getDisplayItems());
3334
35+
// Preview dialog state
36+
let previewDialogOpen = $state(false);
37+
let previewItem = $state<{
38+
uploadedFile?: ChatUploadedFile;
39+
attachment?: DatabaseMessageExtra;
40+
preview?: string;
41+
name?: string;
42+
type?: string;
43+
size?: number;
44+
textContent?: string;
45+
} | null>(null);
3446
47+
function openPreview(item: (typeof displayItems)[0]) {
48+
previewItem = {
49+
uploadedFile: item.uploadedFile,
50+
attachment: item.attachment,
51+
preview: item.preview,
52+
name: item.name,
53+
type: item.type,
54+
size: item.size,
55+
textContent: item.textContent
56+
};
57+
previewDialogOpen = true;
58+
}
59+
60+
function closePreview() {
61+
previewDialogOpen = false;
62+
previewItem = null;
63+
}
3564
3665
function getDisplayItems() {
3766
const items: Array<{
@@ -110,27 +139,45 @@
110139
{#each displayItems as item (item.id)}
111140
{#if item.isImage && item.preview}
112141
<ChatAttachmentImagePreview
142+
class="cursor-pointer"
113143
id={item.id}
114144
name={item.name}
115145
preview={item.preview}
116146
size={item.size}
117-
readonly={readonly}
147+
{readonly}
118148
onRemove={onFileRemove}
119149
height={imageHeight}
120150
width={imageWidth}
121-
imageClass={imageClass}
151+
{imageClass}
152+
onClick={() => openPreview(item)}
122153
/>
123154
{:else}
124155
<ChatAttachmentFilePreview
156+
class="cursor-pointer"
125157
id={item.id}
126158
name={item.name}
127159
type={item.type}
128160
size={item.size}
129-
readonly={readonly}
161+
{readonly}
130162
onRemove={onFileRemove}
131163
textContent={item.textContent}
164+
onClick={() => openPreview(item)}
132165
/>
133166
{/if}
134167
{/each}
135168
</div>
136169
{/if}
170+
171+
{#if previewItem}
172+
<ChatAttachmentPreviewDialog
173+
bind:open={previewDialogOpen}
174+
onClose={closePreview}
175+
uploadedFile={previewItem.uploadedFile}
176+
attachment={previewItem.attachment}
177+
preview={previewItem.preview}
178+
name={previewItem.name}
179+
type={previewItem.type}
180+
size={previewItem.size}
181+
textContent={previewItem.textContent}
182+
/>
183+
{/if}

0 commit comments

Comments
 (0)