Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ useSeoMeta({
</script>

<template>
<UApp :toaster="{ position: 'top-right' }">
<UApp :toaster="{ position: 'top-right' }" :tooltip="{ delayDuration: 200 }">
<NuxtLoadingIndicator color="var(--ui-primary)" />

<NuxtLayout>
Expand Down
104 changes: 104 additions & 0 deletions app/components/DragDropOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { Motion } from 'motion-v'

const { loggedIn } = useUserSession()

defineProps<{
show: boolean
}>()
</script>

<template>
<div
v-if="show && loggedIn"
class="absolute inset-0 z-50 flex items-center justify-center backdrop-blur-lg pointer-events-none"
>
<div class="absolute text-center">
<div class="flex items-center justify-center gap-2">
<Motion
:initial="{
rotate: 0,
scale: 0.5,
opacity: 0
}"
:animate="show ? {
rotate: -15,
scale: 1,
opacity: 1
} : {}"
:transition="{
type: 'spring',
stiffness: 400,
damping: 18,
delay: 0
}"
>
<UIcon name="i-lucide-file-text" class="size-12" />
</Motion>
<Motion
:initial="{
scale: 0.5,
opacity: 0,
y: 0
}"
:animate="show ? {
scale: 1,
opacity: 1,
y: 0
} : {}"
:transition="{
type: 'spring',
stiffness: 500,
damping: 15,
delay: 0.03
}"
>
<UIcon name="i-lucide-file" class="size-14" />
</Motion>

<Motion
:initial="{
rotate: 0,
scale: 0.5,
opacity: 0
}"
:animate="show ? {
rotate: 15,
scale: 1,
opacity: 1
} : {}"
:transition="{
type: 'spring',
stiffness: 400,
damping: 18,
delay: 0.06
}"
>
<UIcon name="i-lucide-file-spreadsheet" class="size-12" />
</Motion>
</div>

<Motion
:initial="{
opacity: 0,
y: 10
}"
:animate="show ? {
opacity: 1,
y: 0
} : {}"
:transition="{
delay: 0.08,
duration: 0.2
}"
>
<p class="text-lg/7 font-medium mt-4">
Drop your files here
</p>
<p class="text-sm/6 text-muted">
Supported formats: Images, PDFs, CSV files
</p>
</Motion>
</div>
</div>
</template>
60 changes: 60 additions & 0 deletions app/components/FileAvatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script setup lang="ts">
interface FileAvatarProps {
name: string
type: string
previewUrl?: string
status?: 'idle' | 'uploading' | 'uploaded' | 'error'
error?: string
removable?: boolean
}

withDefaults(defineProps<FileAvatarProps>(), {
status: 'idle',
removable: false
})

const emit = defineEmits<{
remove: []
}>()
</script>

<template>
<div class="relative group">
<UTooltip arrow :text="removeRandomSuffix(name)">
<UAvatar
size="3xl"
:src="type.startsWith('image/') ? previewUrl : undefined"
:icon="getFileIcon(type, name)"
class="border border-default rounded-lg"
:class="{
'opacity-50': status === 'uploading',
'border-error': status === 'error'
}"
/>
</UTooltip>

<div
v-if="status === 'uploading'"
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-white" />
</div>

<UTooltip v-if="status === 'error'" :text="error">
<div class="absolute inset-0 flex items-center justify-center bg-error/50 rounded-lg">
<UIcon name="i-lucide-alert-circle" class="size-8 text-white" />
</div>
</UTooltip>

<UButton
v-if="removable && status !== 'uploading'"
icon="i-lucide-x"
size="xs"
square
color="neutral"
variant="solid"
class="absolute p-0 -top-1 -right-1 opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
@click="emit('remove')"
/>
</div>
</template>
49 changes: 49 additions & 0 deletions app/components/FileUploadButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
const { loggedIn } = useUserSession()

const emit = defineEmits<{
filesSelected: [files: File[]]
}>()

const inputId = useId()

function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = Array.from(input.files || [])

if (files.length > 0) {
emit('filesSelected', files)
}

input.value = ''
}
</script>

<template>
<UTooltip
:content="{
side: 'top'
}"
:text="!loggedIn ? 'You need to be logged in to upload files' : ''"
>
<label :for="inputId" :class="{ 'cursor-not-allowed opacity-50': !loggedIn }">
<UButton
icon="i-lucide-paperclip"
variant="ghost"
color="neutral"
size="sm"
as="span"
:disabled="!loggedIn"
/>
</label>
<input
:id="inputId"
type="file"
multiple
:accept="FILE_UPLOAD_CONFIG.acceptPattern"
class="hidden"
:disabled="!loggedIn"
@change="handleFileSelect"
>
</UTooltip>
</template>
5 changes: 3 additions & 2 deletions app/components/ModelSelect.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
const { model, models } = useModels()
const { model, models, formatModelName } = useModels()

const items = computed(() => models.map(model => ({
label: model,
label: formatModelName(model),
value: model,
icon: `i-simple-icons-${model.split('/')[0]}`
})))
Expand All @@ -12,6 +12,7 @@ const items = computed(() => models.map(model => ({
<USelectMenu
v-model="model"
:items="items"
size="sm"
:icon="`i-simple-icons-${model.split('/')[0]}`"
variant="ghost"
value-key="value"
Expand Down
122 changes: 122 additions & 0 deletions app/composables/useFileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}

async function uploadFileToBlob(file: File, chatId: string): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
formData.append('chatId', chatId)

return await $fetch('/api/upload', {
method: 'POST',
body: formData
})
}

export function useFileUploadWithStatus(chatId: string) {
const files = ref<FileWithStatus[]>([])
const toast = useToast()
const { loggedIn } = useUserSession()

async function uploadFiles(newFiles: File[]) {
if (!loggedIn.value) {
return
}

const filesWithStatus: FileWithStatus[] = newFiles.map(file => ({
file,
id: crypto.randomUUID(),
previewUrl: createObjectUrl(file),
status: 'uploading' as const
}))

files.value = [...files.value, ...filesWithStatus]

const uploadPromises = filesWithStatus.map(async (fileWithStatus) => {
const index = files.value.findIndex(f => f.id === fileWithStatus.id)
if (index === -1) return

try {
const response = await uploadFileToBlob(fileWithStatus.file, chatId)
files.value[index] = {
...files.value[index]!,
status: 'uploaded',
uploadedUrl: response.url
}
} catch (error) {
const errorMessage = (error as { statusMessage?: string }).statusMessage || 'Upload failed'
toast.add({
title: 'Upload failed',
description: errorMessage,
icon: 'i-lucide-alert-circle',
color: 'error'
})
files.value[index] = {
...files.value[index]!,
status: 'error',
error: errorMessage
}
}
})

await Promise.allSettled(uploadPromises)
}

const { dropzoneRef, isDragging } = useFileUpload({
accept: FILE_UPLOAD_CONFIG.acceptPattern,
multiple: true,
onUpdate: uploadFiles
})

const isUploading = computed(() =>
files.value.some(f => f.status === 'uploading')
)

const uploadedFiles = computed(() =>
files.value
.filter(f => f.status === 'uploaded' && f.uploadedUrl)
.map(f => ({
type: 'file' as const,
mediaType: f.file.type,
url: f.uploadedUrl!
}))
)

function removeFile(id: string) {
const file = files.value.find(f => f.id === id)
if (!file) return

URL.revokeObjectURL(file.previewUrl)
files.value = files.value.filter(f => f.id !== id)

if (file.status === 'uploaded' && file.uploadedUrl) {
$fetch('/api/upload', {
method: 'DELETE',
body: { url: file.uploadedUrl }
}).catch((error) => {
console.error('Failed to delete file from blob:', error)
})
}
}

function clearFiles() {
if (files.value.length === 0) return
files.value.forEach(fileWithStatus => URL.revokeObjectURL(fileWithStatus.previewUrl))
files.value = []
}

onUnmounted(() => {
clearFiles()
})

return {
dropzoneRef,
isDragging,
files,
isUploading,
uploadedFiles,
addFiles: uploadFiles,
removeFile,
clearFiles
}
}
18 changes: 17 additions & 1 deletion app/composables/useModels.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
export function formatModelName(modelId: string): string {
const acronyms = ['gpt'] // words that should be uppercase
const modelName = modelId.split('/')[1] || modelId

return modelName
.split('-')
.map((word) => {
const lowerWord = word.toLowerCase()
return acronyms.includes(lowerWord)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
}

export function useModels() {
const models = [
'openai/gpt-5-nano',
Expand All @@ -9,6 +24,7 @@ export function useModels() {

return {
models,
model
model,
formatModelName
}
}
Loading