Skip to content

Commit d3bf027

Browse files
committed
chore(storage): allow multiple files upload
1 parent 1faf68d commit d3bf027

File tree

3 files changed

+58
-120
lines changed

3 files changed

+58
-120
lines changed

_nuxthub/server/utils/bucket.ts

+13-31
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { R2Bucket, R2ListOptions, ReadableStreamDefaultReader } from '@cloudflare/workers-types/experimental'
1+
import type { R2Bucket, R2ListOptions } from '@cloudflare/workers-types/experimental'
22
import mime from 'mime'
33
import { imageMeta } from 'image-meta'
44
import { defu } from 'defu'
@@ -49,8 +49,12 @@ export async function serveFiles (bucket: R2Bucket, options: R2ListOptions = {})
4949
return listed.objects
5050
}
5151

52-
export function getContentType (key: string) {
53-
return mime.getType(key) || 'application/octet-stream'
52+
export function getContentType (pathOrExtension?: string) {
53+
return (pathOrExtension && mime.getType(pathOrExtension)) || 'application/octet-stream'
54+
}
55+
56+
export function getExtension (type?: string) {
57+
return (type && mime.getExtension(type)) || ''
5458
}
5559

5660
export function getMetadata (type: string, buffer: Buffer) {
@@ -61,33 +65,11 @@ export function getMetadata (type: string, buffer: Buffer) {
6165
}
6266
}
6367

64-
export async function processStream(
65-
reader: ReadableStreamDefaultReader<Uint8Array>
66-
): Promise<Uint8Array> {
67-
let { value, done } = await reader.read()
68-
69-
const results: Array<Uint8Array> = []
70-
71-
while (!done && value) {
72-
const newRead = await reader.read()
73-
74-
results.push(value)
75-
76-
value = newRead.value
77-
done = newRead.done
68+
export function toArrayBuffer (buffer: Buffer) {
69+
const arrayBuffer = new ArrayBuffer(buffer.length)
70+
const view = new Uint8Array(arrayBuffer)
71+
for (let i = 0; i < buffer.length; ++i) {
72+
view[i] = buffer[i]
7873
}
79-
80-
const result = new Uint8Array(
81-
// total size
82-
results.reduce((acc, value) => acc + value.length, 0)
83-
)
84-
85-
// Create a new array with total length and merge all source arrays.
86-
let offset = 0
87-
results.forEach((item) => {
88-
result.set(item, offset)
89-
offset += item.length
90-
})
91-
92-
return result
74+
return arrayBuffer
9375
}

pages/storage.vue

+26-68
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,61 @@
1-
<script setup>
1+
<script setup lang="ts">
22
definePageMeta({
33
middleware: 'auth'
44
})
55
const loading = ref(false)
6-
const newFileKey = ref('')
7-
const newFileValue = ref()
8-
const newFileKeyInput = ref()
6+
const newFilesValue = ref<File[]>([])
97
const uploadRef = ref()
108
119
const toast = useToast()
1210
const { user, clear } = useUserSession()
1311
const { data: storage } = await useFetch('/api/storage')
1412
1513
async function addFile () {
16-
const key = newFileKey.value.trim().replace(/\s/g, '-')
17-
if (!key || !newFileValue.value) {
18-
toast.add({ title: `Missing ${!key ? 'key' : 'file'}.`, color: 'red' })
14+
if (!newFilesValue.value.length) {
15+
toast.add({ title: 'Missing files.', color: 'red' })
1916
return
2017
}
2118
2219
loading.value = true
2320
2421
try {
2522
const formData = new FormData()
26-
formData.append('data', new Blob([JSON.stringify({ key })], { type: 'application/json' }))
27-
formData.append('file', newFileValue.value)
28-
const file = await $fetch('/api/storage', {
23+
newFilesValue.value.forEach((file) => formData.append('files', file))
24+
const files = await $fetch('/api/storage', {
2925
method: 'PUT',
3026
body: formData
3127
})
32-
const fileIndex = storage.value.findIndex(e => e.key === file.key)
33-
if (fileIndex !== -1) {
34-
storage.value.splice(fileIndex, 1, file)
35-
} else {
36-
storage.value.push(file)
37-
}
38-
toast.add({ title: `File "${key}" created.` })
39-
newFileKey.value = ''
40-
newFileValue.value = null
41-
nextTick(() => {
42-
newFileKeyInput.value?.input?.focus()
43-
})
44-
} catch (err) {
45-
if (err.data?.data?.issues) {
46-
const title = err.data.data.issues.map(issue => issue.message).join('\n')
47-
toast.add({ title, color: 'red' })
48-
}
28+
storage.value!.push(...files)
29+
toast.add({ title: 'Files created.' })
30+
newFilesValue.value = []
31+
} catch (err: any) {
32+
const title = err.data?.data?.issues?.map((issue: any) => issue.message).join('\n') || err.message()
33+
toast.add({ title, color: 'red' })
4934
}
5035
loading.value = false
5136
}
5237
53-
function onFileSelect (e) {
38+
function onFileSelect (e: any) {
5439
const target = e.target
5540
5641
// clone FileList so the reference does not clear due to following target clear
57-
const files = [...(target.files || [])]
58-
if (files.length) {
59-
newFileValue.value = files[0]
60-
newFileKey.value = files[0].name
61-
}
42+
newFilesValue.value = [...(target.files || [])]
6243
6344
// Clear the input value so that the same file can be uploaded again
6445
target.value = ''
6546
}
6647
67-
async function deleteFile (key) {
48+
async function deleteFile (key: string) {
6849
try {
6950
await useFetch(`/api/storage/${key}`, { method: 'DELETE' })
70-
storage.value = storage.value.filter(t => t.key !== key)
51+
storage.value = storage.value!.filter(t => t.key !== key)
7152
toast.add({ title: `File "${key}" deleted.` })
72-
} catch (err) {
73-
if (err.data?.data?.issues) {
74-
const title = err.data.data.issues.map(issue => issue.message).join('\n')
75-
toast.add({ title, color: 'red' })
76-
}
53+
} catch (err: any) {
54+
const title = err.data?.data?.issues?.map((issue: any) => issue.message).join('\n') || err.message()
55+
toast.add({ title, color: 'red' })
7756
}
7857
}
7958
80-
onMounted(async () => {
81-
// FIXME
82-
// storage.value = await Promise.all(storage.value.map(async (file) => {
83-
// const { data: { body } } = await useFetch(`/api/storage/${file.key}`, { params: { populate: true } })
84-
// return {
85-
// ...file,
86-
// body
87-
// }
88-
// }))
89-
})
90-
9159
const items = [[{
9260
label: 'Logout',
9361
icon: 'i-heroicons-arrow-left-on-rectangle',
@@ -114,18 +82,7 @@ const items = [[{
11482

11583
<div class="flex items-center gap-2">
11684
<UInput
117-
ref="newFileKeyInput"
118-
v-model="newFileKey"
119-
name="fileKey"
120-
:disabled="loading"
121-
class="flex-1"
122-
placeholder="key"
123-
autocomplete="off"
124-
autofocus
125-
:ui="{ wrapper: 'flex-1' }"
126-
/>
127-
<UInput
128-
:model-value="newFileValue?.name"
85+
:model-value="newFilesValue?.map((file) => file.name).join(', ')"
12986
name="fileValue"
13087
disabled
13188
class="flex-1"
@@ -137,7 +94,8 @@ const items = [[{
13794
tabindex="-1"
13895
accept="jpeg, png"
13996
type="file"
140-
name="file"
97+
name="files"
98+
multiple
14199
class="hidden"
142100
@change="onFileSelect"
143101
>
@@ -147,7 +105,7 @@ const items = [[{
147105
@click="uploadRef.click()"
148106
/>
149107

150-
<UButton type="submit" icon="i-heroicons-plus-20-solid" :loading="loading" :disabled="false" />
108+
<UButton type="submit" icon="i-heroicons-plus-20-solid" :loading="loading" :disabled="!newFilesValue.length" />
151109
</div>
152110

153111
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
@@ -163,13 +121,13 @@ const items = [[{
163121
class="overflow-hidden relative"
164122
>
165123
<img v-if="file.httpMetadata?.contentType?.startsWith('image/') && file.body" :src="`data:${file.httpMetadata.contentType};base64,${(file.body)}`" class="h-36 w-full object-cover">
166-
<div v-else class="h-36 w-full flex items-center justify-center">
124+
<div class="h-36 w-full flex items-center justify-center p-2 text-center">
167125
{{ file.key }}
168126
</div>
169127
<div class="flex flex-col gap-1 p-2 border-t border-gray-200 dark:border-gray-800">
170128
<span class="text-sm font-medium">{{ file.key }}</span>
171-
<div class="flex items-center justify-between">
172-
<span class="text-xs">{{ file.httpMetadata?.contentType || '-' }}</span>
129+
<div class="flex items-center justify-between gap-1">
130+
<span class="text-xs truncate">{{ file.httpMetadata?.contentType || '-' }}</span>
173131
<span class="text-xs">{{ file.size ? `${Math.round(file.size / Math.pow(1024, 2) * 100) / 100}MB` : '-' }}</span>
174132
</div>
175133
</div>

server/api/storage/index.put.ts

+19-21
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,34 @@
1-
import type { MultiPartData } from 'h3'
1+
import { randomUUID } from 'uncrypto'
22

33
export default eventHandler(async (event) => {
44
await requireUserSession(event)
55

6-
const form: MultiPartData[] | undefined = await readMultipartFormData(event)
7-
if (!form) {
8-
throw createError('Request body must be multipart.')
9-
}
10-
const dataPart = form.find((part) => part.name === 'data')
11-
const filePart = form.find((part) => part.name === 'file')
12-
if (!dataPart || !filePart) {
13-
throw createError(`Missing ${!dataPart ? 'data' : 'file'} body param.`)
6+
let files = (await readMultipartFormData(event) || [])
7+
8+
// Filter only files
9+
files = files.filter((file) => Boolean(file.filename))
10+
if (!files) {
11+
throw createError({ statusCode: 400, message: 'Missing files' })
1412
}
13+
const objects = []
1514

1615
try {
17-
const data = JSON.parse(dataPart.data.toString())
18-
const file = filePart.data
19-
const type = filePart.type || getContentType(data.key)
20-
const httpMetadata = { contentType: type }
21-
const customMetadata = getMetadata(type, filePart.data)
22-
23-
// Set entry for the current user
24-
25-
const object = await useBucket().put(data.key, file.toString(), { httpMetadata, customMetadata })
26-
return {
27-
...object,
28-
body: file.toString('base64')
16+
for (const file of files) {
17+
const type = file.type || getContentType(file.filename)
18+
const extension = getExtension(type)
19+
// TODO: ensure filename unicity
20+
const filename = [randomUUID(), extension].filter(Boolean).join('.')
21+
const httpMetadata = { contentType: type }
22+
const customMetadata = getMetadata(type, file.data)
23+
const object = await useBucket().put(filename, toArrayBuffer(file.data), { httpMetadata, customMetadata })
24+
objects.push(object)
2925
}
3026
} catch (e: any) {
3127
throw createError({
3228
statusCode: 500,
3329
message: `Storage error: ${e.message}`
3430
})
3531
}
32+
33+
return objects
3634
})

0 commit comments

Comments
 (0)