Skip to content

Commit cffcf75

Browse files
HugoRCDatinuxbenjamincanac
authored
feat: handle file upload (#80)
Co-authored-by: Sébastien Chopin <atinux@gmail.com> Co-authored-by: Benjamin Canac <canacb1@gmail.com>
1 parent edf9da5 commit cffcf75

File tree

18 files changed

+653
-63
lines changed

18 files changed

+653
-63
lines changed

app/app.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ useSeoMeta({
3232
</script>
3333

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

3838
<NuxtLayout>

app/components/DragDropOverlay.vue

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script setup lang="ts">
2+
import { Motion } from 'motion-v'
3+
4+
const { loggedIn } = useUserSession()
5+
6+
defineProps<{
7+
show: boolean
8+
}>()
9+
</script>
10+
11+
<template>
12+
<div
13+
v-if="show && loggedIn"
14+
class="absolute inset-0 z-50 flex items-center justify-center backdrop-blur-lg pointer-events-none"
15+
>
16+
<div class="absolute text-center">
17+
<div class="flex items-center justify-center gap-2">
18+
<Motion
19+
:initial="{
20+
rotate: 0,
21+
scale: 0.5,
22+
opacity: 0
23+
}"
24+
:animate="show ? {
25+
rotate: -15,
26+
scale: 1,
27+
opacity: 1
28+
} : {}"
29+
:transition="{
30+
type: 'spring',
31+
stiffness: 400,
32+
damping: 18,
33+
delay: 0
34+
}"
35+
>
36+
<UIcon name="i-lucide-file-text" class="size-12" />
37+
</Motion>
38+
<Motion
39+
:initial="{
40+
scale: 0.5,
41+
opacity: 0,
42+
y: 0
43+
}"
44+
:animate="show ? {
45+
scale: 1,
46+
opacity: 1,
47+
y: 0
48+
} : {}"
49+
:transition="{
50+
type: 'spring',
51+
stiffness: 500,
52+
damping: 15,
53+
delay: 0.03
54+
}"
55+
>
56+
<UIcon name="i-lucide-file" class="size-14" />
57+
</Motion>
58+
59+
<Motion
60+
:initial="{
61+
rotate: 0,
62+
scale: 0.5,
63+
opacity: 0
64+
}"
65+
:animate="show ? {
66+
rotate: 15,
67+
scale: 1,
68+
opacity: 1
69+
} : {}"
70+
:transition="{
71+
type: 'spring',
72+
stiffness: 400,
73+
damping: 18,
74+
delay: 0.06
75+
}"
76+
>
77+
<UIcon name="i-lucide-file-spreadsheet" class="size-12" />
78+
</Motion>
79+
</div>
80+
81+
<Motion
82+
:initial="{
83+
opacity: 0,
84+
y: 10
85+
}"
86+
:animate="show ? {
87+
opacity: 1,
88+
y: 0
89+
} : {}"
90+
:transition="{
91+
delay: 0.08,
92+
duration: 0.2
93+
}"
94+
>
95+
<p class="text-lg/7 font-medium mt-4">
96+
Drop your files here
97+
</p>
98+
<p class="text-sm/6 text-muted">
99+
Supported formats: Images, PDFs, CSV files
100+
</p>
101+
</Motion>
102+
</div>
103+
</div>
104+
</template>

app/components/FileAvatar.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script setup lang="ts">
2+
interface FileAvatarProps {
3+
name: string
4+
type: string
5+
previewUrl?: string
6+
status?: 'idle' | 'uploading' | 'uploaded' | 'error'
7+
error?: string
8+
removable?: boolean
9+
}
10+
11+
withDefaults(defineProps<FileAvatarProps>(), {
12+
status: 'idle',
13+
removable: false
14+
})
15+
16+
const emit = defineEmits<{
17+
remove: []
18+
}>()
19+
</script>
20+
21+
<template>
22+
<div class="relative group">
23+
<UTooltip arrow :text="removeRandomSuffix(name)">
24+
<UAvatar
25+
size="3xl"
26+
:src="type.startsWith('image/') ? previewUrl : undefined"
27+
:icon="getFileIcon(type, name)"
28+
class="border border-default rounded-lg"
29+
:class="{
30+
'opacity-50': status === 'uploading',
31+
'border-error': status === 'error'
32+
}"
33+
/>
34+
</UTooltip>
35+
36+
<div
37+
v-if="status === 'uploading'"
38+
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg"
39+
>
40+
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-white" />
41+
</div>
42+
43+
<UTooltip v-if="status === 'error'" :text="error">
44+
<div class="absolute inset-0 flex items-center justify-center bg-error/50 rounded-lg">
45+
<UIcon name="i-lucide-alert-circle" class="size-8 text-white" />
46+
</div>
47+
</UTooltip>
48+
49+
<UButton
50+
v-if="removable && status !== 'uploading'"
51+
icon="i-lucide-x"
52+
size="xs"
53+
square
54+
color="neutral"
55+
variant="solid"
56+
class="absolute p-0 -top-1 -right-1 opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
57+
@click="emit('remove')"
58+
/>
59+
</div>
60+
</template>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
const { loggedIn } = useUserSession()
3+
4+
const emit = defineEmits<{
5+
filesSelected: [files: File[]]
6+
}>()
7+
8+
const inputId = useId()
9+
10+
function handleFileSelect(e: Event) {
11+
const input = e.target as HTMLInputElement
12+
const files = Array.from(input.files || [])
13+
14+
if (files.length > 0) {
15+
emit('filesSelected', files)
16+
}
17+
18+
input.value = ''
19+
}
20+
</script>
21+
22+
<template>
23+
<UTooltip
24+
:content="{
25+
side: 'top'
26+
}"
27+
:text="!loggedIn ? 'You need to be logged in to upload files' : ''"
28+
>
29+
<label :for="inputId" :class="{ 'cursor-not-allowed opacity-50': !loggedIn }">
30+
<UButton
31+
icon="i-lucide-paperclip"
32+
variant="ghost"
33+
color="neutral"
34+
size="sm"
35+
as="span"
36+
:disabled="!loggedIn"
37+
/>
38+
</label>
39+
<input
40+
:id="inputId"
41+
type="file"
42+
multiple
43+
:accept="FILE_UPLOAD_CONFIG.acceptPattern"
44+
class="hidden"
45+
:disabled="!loggedIn"
46+
@change="handleFileSelect"
47+
>
48+
</UTooltip>
49+
</template>

app/components/ModelSelect.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script setup lang="ts">
2-
const { model, models } = useModels()
2+
const { model, models, formatModelName } = useModels()
33
44
const items = computed(() => models.map(model => ({
5-
label: model,
5+
label: formatModelName(model),
66
value: model,
77
icon: `i-simple-icons-${model.split('/')[0]}`
88
})))
@@ -12,6 +12,7 @@ const items = computed(() => models.map(model => ({
1212
<USelectMenu
1313
v-model="model"
1414
:items="items"
15+
size="sm"
1516
:icon="`i-simple-icons-${model.split('/')[0]}`"
1617
variant="ghost"
1718
value-key="value"

app/composables/useFileUpload.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
interface BlobResult {
2+
pathname: string
3+
url?: string
4+
contentType?: string
5+
size: number
6+
}
7+
8+
function createObjectUrl(file: File): string {
9+
return URL.createObjectURL(file)
10+
}
11+
12+
function fileToInput(file: File): HTMLInputElement {
13+
const dataTransfer = new DataTransfer()
14+
dataTransfer.items.add(file)
15+
16+
const input = document.createElement('input')
17+
input.type = 'file'
18+
input.files = dataTransfer.files
19+
20+
return input
21+
}
22+
23+
export function useFileUploadWithStatus(chatId: string) {
24+
const files = ref<FileWithStatus[]>([])
25+
const toast = useToast()
26+
const { loggedIn } = useUserSession()
27+
28+
const upload = useUpload(`/api/upload/${chatId}`, { method: 'PUT' })
29+
30+
async function uploadFiles(newFiles: File[]) {
31+
if (!loggedIn.value) {
32+
return
33+
}
34+
35+
const filesWithStatus: FileWithStatus[] = newFiles.map(file => ({
36+
file,
37+
id: crypto.randomUUID(),
38+
previewUrl: createObjectUrl(file),
39+
status: 'uploading' as const
40+
}))
41+
42+
files.value = [...files.value, ...filesWithStatus]
43+
44+
const uploadPromises = filesWithStatus.map(async (fileWithStatus) => {
45+
const index = files.value.findIndex(f => f.id === fileWithStatus.id)
46+
if (index === -1) return
47+
48+
try {
49+
const input = fileToInput(fileWithStatus.file)
50+
const response = await upload(input) as BlobResult | BlobResult[] | undefined
51+
52+
if (!response) {
53+
throw new Error('Upload failed')
54+
}
55+
56+
const result = Array.isArray(response) ? response[0] : response
57+
58+
if (!result) {
59+
throw new Error('Upload failed')
60+
}
61+
62+
files.value[index] = {
63+
...files.value[index]!,
64+
status: 'uploaded',
65+
uploadedUrl: result.url,
66+
uploadedPathname: result.pathname
67+
}
68+
} catch (error) {
69+
const errorMessage = (error as { data?: { message?: string } }).data?.message
70+
|| (error as Error).message
71+
|| 'Upload failed'
72+
toast.add({
73+
title: 'Upload failed',
74+
description: errorMessage,
75+
icon: 'i-lucide-alert-circle',
76+
color: 'error'
77+
})
78+
files.value[index] = {
79+
...files.value[index]!,
80+
status: 'error',
81+
error: errorMessage
82+
}
83+
}
84+
})
85+
86+
await Promise.allSettled(uploadPromises)
87+
}
88+
89+
const { dropzoneRef, isDragging } = useFileUpload({
90+
accept: FILE_UPLOAD_CONFIG.acceptPattern,
91+
multiple: true,
92+
onUpdate: uploadFiles
93+
})
94+
95+
const isUploading = computed(() =>
96+
files.value.some(f => f.status === 'uploading')
97+
)
98+
99+
const uploadedFiles = computed(() =>
100+
files.value
101+
.filter(f => f.status === 'uploaded' && f.uploadedUrl)
102+
.map(f => ({
103+
type: 'file' as const,
104+
mediaType: f.file.type,
105+
url: f.uploadedUrl!
106+
}))
107+
)
108+
109+
function removeFile(id: string) {
110+
const file = files.value.find(f => f.id === id)
111+
if (!file) return
112+
113+
URL.revokeObjectURL(file.previewUrl)
114+
files.value = files.value.filter(f => f.id !== id)
115+
116+
if (file.status === 'uploaded' && file.uploadedPathname) {
117+
fetch(`/api/upload/${file.uploadedPathname}`, {
118+
method: 'DELETE'
119+
}).catch((error) => {
120+
console.error('Failed to delete file from blob:', error)
121+
})
122+
}
123+
}
124+
125+
function clearFiles() {
126+
if (files.value.length === 0) return
127+
files.value.forEach(fileWithStatus => URL.revokeObjectURL(fileWithStatus.previewUrl))
128+
files.value = []
129+
}
130+
131+
onUnmounted(() => {
132+
clearFiles()
133+
})
134+
135+
return {
136+
dropzoneRef,
137+
isDragging,
138+
files,
139+
isUploading,
140+
uploadedFiles,
141+
addFiles: uploadFiles,
142+
removeFile,
143+
clearFiles
144+
}
145+
}

0 commit comments

Comments
 (0)