image modal

This commit is contained in:
Thomas G. Lopes 2025-06-17 22:54:41 +01:00
parent ea46448dd9
commit e3dd9bf073
9 changed files with 168 additions and 9 deletions

View file

@ -92,6 +92,7 @@ export const createAndAddMessage = mutation({
images: v.optional(v.array(v.object({
url: v.string(),
storage_id: v.string(),
fileName: v.optional(v.string()),
}))),
},
handler: async (

View file

@ -44,6 +44,7 @@ export const create = mutation({
images: v.optional(v.array(v.object({
url: v.string(),
storage_id: v.string(),
fileName: v.optional(v.string()),
}))),
},
handler: async (ctx, args): Promise<Id<'messages'>> => {

View file

@ -62,6 +62,7 @@ export default defineSchema({
v.object({
url: v.string(),
storage_id: v.string(),
fileName: v.optional(v.string()),
})
)
),

View file

@ -24,6 +24,7 @@
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
iconSm: 'size-7',
},
},
defaultVariants: {

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { Modal } from '$lib/components/ui/modal';
import { Button } from '$lib/components/ui/button';
import DownloadIcon from '~icons/lucide/download';
import ExternalLinkIcon from '~icons/lucide/external-link';
import XIcon from '~icons/lucide/x';
import Tooltip from '../tooltip.svelte';
type Props = {
open?: boolean;
imageUrl: string;
fileName?: string;
};
let { open = $bindable(false), imageUrl, fileName = 'image' }: Props = $props();
async function downloadImage() {
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to download image:', error);
}
}
function openInNewTab() {
window.open(imageUrl, '_blank');
}
</script>
<Modal bind:open>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{fileName}</h2>
<div class="flex items-center gap-2">
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={downloadImage} {...tooltip.trigger}>
<DownloadIcon class="size-4" />
</Button>
{/snippet}
Download image
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={openInNewTab} {...tooltip.trigger}>
<ExternalLinkIcon class="size-4" />
</Button>
{/snippet}
Open in new tab
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
onclick={() => (open = false)}
{...tooltip.trigger}
>
<XIcon class="size-4" />
</Button>
{/snippet}
Close
</Tooltip>
</div>
</div>
<div class="mt-2 flex items-center justify-center">
<img src={imageUrl} alt={fileName} class="max-h-[60vh] max-w-full rounded-lg object-contain" />
</div>
</Modal>

View file

@ -0,0 +1 @@
export { default as ImageModal } from './image-modal.svelte';

View file

@ -24,6 +24,7 @@ const reqBodySchema = z.object({
images: z.array(z.object({
url: z.string(),
storage_id: z.string(),
fileName: z.string().optional(),
})).optional(),
});

View file

@ -23,6 +23,7 @@
import { compressImage } from '$lib/utils/image-compression';
import { useConvexClient } from 'convex-svelte';
import { FileUpload, Popover } from 'melt/builders';
import { ImageModal } from '$lib/components/ui/image-modal';
import { Avatar } from 'melt/components';
import { Debounced, ElementSize, IsMounted, ScrollState } from 'runed';
import SendIcon from '~icons/lucide/arrow-up';
@ -169,9 +170,14 @@
]);
let message = $state('');
let selectedImages = $state<{ url: string; storage_id: string }[]>([]);
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
let isUploading = $state(false);
let fileInput = $state<HTMLInputElement>();
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
open: false,
imageUrl: '',
fileName: '',
});
usePrompt(
() => message,
@ -197,7 +203,7 @@
if (!files.length || !session.current?.session.token) return;
isUploading = true;
const uploadedFiles: { url: string; storage_id: string }[] = [];
const uploadedFiles: { url: string; storage_id: string; fileName?: string }[] = [];
try {
for (const file of files) {
@ -234,7 +240,7 @@
});
if (url) {
uploadedFiles.push({ url, storage_id: storageId });
uploadedFiles.push({ url, storage_id: storageId, fileName: file.name });
}
}
@ -250,6 +256,22 @@
selectedImages = selectedImages.filter((_, i) => i !== index);
}
function openImageModal(imageUrl: string, fileName: string) {
imageModal = {
open: true,
imageUrl,
fileName,
};
}
function closeImageModal() {
imageModal = {
open: false,
imageUrl: '',
fileName: '',
};
}
$effect(() => {
if (fileUpload.selected.size > 0) {
handleFileChange(Array.from(fileUpload.selected));
@ -610,11 +632,17 @@
<div
class="group border-secondary-foreground/[0.08] bg-secondary-foreground/[0.02] hover:bg-secondary-foreground/10 relative flex h-12 w-12 max-w-full shrink-0 items-center justify-center gap-2 rounded-xl border border-solid p-0 transition-[width,height] duration-500"
>
<img
src={image.url}
alt="Uploaded"
class="size-10 rounded-lg object-cover opacity-100 transition-opacity"
/>
<button
type="button"
onclick={() => openImageModal(image.url, image.fileName || 'image')}
class="rounded-lg"
>
<img
src={image.url}
alt="Uploaded"
class="size-10 rounded-lg object-cover opacity-100 transition-opacity"
/>
</button>
<button
type="button"
onclick={() => removeImage(index)}
@ -742,4 +770,10 @@
</div>
</div>
{/if}
<ImageModal
bind:open={imageModal.open}
imageUrl={imageModal.imageUrl}
fileName={imageModal.fileName}
/>
</Sidebar.Root>

View file

@ -5,6 +5,7 @@
import { CopyButton } from '$lib/components/ui/copy-button';
import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte';
import { ImageModal } from '$lib/components/ui/image-modal';
const style = tv({
base: 'prose rounded-lg p-2',
@ -21,6 +22,28 @@
};
let { message }: Props = $props();
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
open: false,
imageUrl: '',
fileName: ''
});
function openImageModal(imageUrl: string, fileName: string) {
imageModal = {
open: true,
imageUrl,
fileName
};
}
function closeImageModal() {
imageModal = {
open: false,
imageUrl: '',
fileName: ''
};
}
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0)}
@ -28,7 +51,17 @@
{#if message.images && message.images.length > 0}
<div class="flex flex-wrap gap-2 mb-2">
{#each message.images as image}
<img src={image.url} alt="Uploaded" class="max-w-xs rounded-lg" />
<button
type="button"
onclick={() => openImageModal(image.url, image.fileName || 'image')}
class="rounded-lg"
>
<img
src={image.url}
alt={image.fileName || 'Uploaded'}
class="max-w-xs rounded-lg hover:opacity-80 transition-opacity"
/>
</button>
{/each}
</div>
{/if}
@ -52,4 +85,11 @@
<CopyButton class="size-7" text={message.content} />
</div>
</div>
<ImageModal
bind:open={imageModal.open}
imageUrl={imageModal.imageUrl}
fileName={imageModal.fileName}
onClose={closeImageModal}
/>
{/if}