supabase
blockdropzone-nuxtjssource: ok · hit
Dropzone (File Upload) for Nuxt and Supabase
Displays a control for easier uploading of files directly to Supabase Storage.
npx shadcn add "https://supabase.com/ui/r/dropzone-nuxtjs.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@supabase/supabase-js@latest@vueuse/corelucide-vue-next
Registry dependencies
button
Files4 · source cached 10/14/2026, 11:42:57 AM
registry/default/dropzone/nuxtjs/app/components/dropzone.vue
<script setup lang="ts">
import { provide, inject } from 'vue'
import { cn } from '@/lib/utils'
interface File {
name: string
errors: Array<{ message: string }>
size: number
lastModified?: number
type?: string
preview?: string
}
export interface DropzoneProps {
className?: string
isDragActive?: boolean
isDragReject?: boolean
isSuccess?: boolean
maxFiles?: number
maxFileSize?: number
inputRef?: HTMLInputElement | null
setFiles: (files: File[]) => void
onUpload: () => Promise<void>
loading?: boolean
successes: string[]
errors: Array<{ name: string; message: string }>
files: File[]
getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>
getInputProps: () => Record<string, unknown>
}
export const formatBytes = (
bytes: number,
decimals = 2,
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
) => {
const k = 1000
const dm = decimals < 0 ? 0 : decimals
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'
const i = size
? Math.max(0, sizes.indexOf(size))
: Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
const DropzoneContext = Symbol('DropzoneContext')
export function useDropzoneContext() {
const ctx = inject<DropzoneProps>(DropzoneContext)
if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')
return ctx
}
const props = defineProps<DropzoneProps>()
provide(DropzoneContext, props)
</script>
<template>
<div
v-bind="
props.getRootProps({
class: cn(
'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
props.className,
props.isSuccess ? 'border-solid' : 'border-dashed',
props.isDragActive && 'border-primary bg-primary/10',
((props.isDragActive && props.isDragReject) ||
(props.errors.length > 0 && !props.isSuccess) ||
props.files.some((f) => f.errors.length !== 0)) &&
'border-destructive bg-destructive/10'
),
})
"
>
<input v-bind="props.getInputProps()" />
<slot />
</div>
</template>
registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue
<script setup lang="ts">
import { Upload } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useDropzoneContext, formatBytes } from './dropzone.vue'
const props = defineProps<{ className?: string }>()
const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
</script>
<template>
<div v-if="!isSuccess" :class="cn('flex flex-col items-center gap-y-2', props.className)">
<Upload size="20" class="text-muted-foreground" aria-hidden="true" />
<p class="text-sm">
Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}
</p>
<div class="flex flex-col items-center gap-y-1">
<p class="text-xs text-muted-foreground">
Drag and drop or
<button
type="button"
class="underline cursor-pointer hover:text-foreground"
@click="inputRef?.click()"
>
select file{{ maxFiles !== 1 ? 's' : '' }}
</button>
to upload
</p>
<p v-if="maxFileSize !== Infinity" class="text-xs text-muted-foreground">
Maximum file size: {{ formatBytes(maxFileSize) }}
</p>
</div>
</div>
</template>
registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CheckCircle, File, Loader2, X } from 'lucide-vue-next'
import { formatBytes, useDropzoneContext } from './dropzone.vue'
import { computed } from 'vue';
const props = defineProps<{ className?: string }>()
const {
files,
setFiles,
onUpload,
loading,
successes,
errors,
maxFileSize,
maxFiles,
isSuccess,
} = useDropzoneContext()
const exceedMaxFiles = computed(() => files.length > maxFiles)
const fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))
function formatFileErrors(file: typeof files[number]) {
if (!file.errors || file.errors.length === 0) return ''
return file.errors
.map(e =>
e.message.startsWith('File is larger than')
? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`
: e.message
)
.join(', ')
}
function handleRemoveFile(filename: string) {
setFiles(files.filter(f => f.name !== filename))
}
</script>
<template>
<div :class="cn('flex flex-col', props.className)">
<!-- Success State -->
<div v-if="isSuccess" class="flex flex-row items-center gap-x-2 justify-center" role="status" aria-live="polite">
<CheckCircle size="16" class="text-primary" aria-hidden="true"/>
<p class="text-primary text-sm">
Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}
</p>
</div>
<!-- File list -->
<template v-else>
<div
v-for="(file, idx) in files"
:key="file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx"
class="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4"
>
<div v-if="file.type.startsWith('image/')"
class="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
<img :src="file.preview" :alt="file.name" class="object-cover" />
</div>
<div v-else class="h-10 w-10 rounded border bg-muted flex items-center justify-center">
<File size="18" aria-hidden="true" />
</div>
<div class="shrink grow flex flex-col items-start truncate">
<p class="text-sm truncate max-w-full" :title="file.name">
{{ file.name }}
</p>
<!-- ARIA live region for status messages -->
<div aria-live="polite" aria-atomic="true">
<!-- Errors -->
<p v-if="file.errors.length > 0" class="text-xs text-destructive">
{{ formatFileErrors(file) }}
</p>
<!-- Uploading -->
<p v-else-if="loading" class="text-xs text-muted-foreground">Uploading file...</p>
<!-- Failed -->
<p v-else-if="fileErrorMessages.has(file.name)" class="text-xs text-destructive">
Failed to upload: {{ fileErrorMessages.get(file.name) }}
</p>
<!-- Success -->
<p v-else-if="successes.includes(file.name)" class="text-xs text-primary">
Successfully uploaded file
</p>
<!-- Normal -->
<p v-else class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
</div>
</div>
<Button
v-if="!loading && !successes.includes(file.name)"
aria-label="Remove file Button"
size="icon"
variant="link"
class="shrink-0 text-muted-foreground hover:text-foreground"
@click="handleRemoveFile(file.name)"
>
<X />
</Button>
</div>
<!-- Too many files -->
<p v-if="exceedMaxFiles" class="text-sm text-left mt-2 text-destructive">
You may upload only up to {{ maxFiles }} files, please remove
{{ files.length - maxFiles }} file(s).
</p>
<!-- Upload button -->
<div v-if="files.length > 0 && !exceedMaxFiles" class="mt-2">
<Button
variant="outline"
:disabled="files.some(f => f.errors.length) || loading"
@click="onUpload"
>
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
<template v-if="loading">Uploading...</template>
<template v-else>Upload files</template>
</Button>
</div>
</template>
</div>
</template>
registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts
import { useDropZone } from '@vueuse/core'
import { computed, onUnmounted, ref, watch } from 'vue'
// @ts-ignore
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
export interface FileWithPreview extends File {
preview?: string
errors: { code: string; message: string }[]
}
export type UseSupabaseUploadOptions = {
bucketName: string
path?: string
allowedMimeTypes?: string[]
maxFileSize?: number
maxFiles?: number
cacheControl?: number
upsert?: boolean
}
function validateFileType(file: File, allowedTypes: string[]) {
if (!allowedTypes.length) return []
const isValid = allowedTypes.some((t) =>
t.endsWith('/*') ? file.type.startsWith(t.replace('/*', '')) : file.type === t
)
return isValid ? [] : [{ code: 'invalid-type', message: 'Invalid file type' }]
}
function validateFileSize(file: File, maxSize: number) {
return file.size > maxSize
? [{ code: 'file-too-large', message: `File is larger than allowed size` }]
: []
}
export function useSupabaseUpload(options: UseSupabaseUploadOptions) {
const {
bucketName,
path,
allowedMimeTypes = [],
maxFileSize = Number.POSITIVE_INFINITY,
maxFiles = 1,
cacheControl = 3600,
upsert = false,
} = options
const files = ref<FileWithPreview[]>([])
const loading = ref(false)
const errors = ref<{ name: string; message: string }[]>([])
const successes = ref<string[]>([])
const isSuccess = computed(() => {
if (!errors.value.length && !successes.value.length) return false
return !errors.value.length && successes.value.length === files.value.length
})
const dropZoneRef = ref<HTMLElement | null>(null)
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop(droppedFiles: File[] | null) {
if (!droppedFiles) return
const newFiles: FileWithPreview[] = droppedFiles.map((file) => ({
...(file as FileWithPreview),
preview: URL.createObjectURL(file),
errors: [
...validateFileType(file, allowedMimeTypes),
...validateFileSize(file, maxFileSize),
],
}))
files.value = [...files.value, ...newFiles]
},
})
const onUpload = async () => {
loading.value = true
try {
const filesWithErrors = errors.value.map((e) => e.name)
const filesToUpload =
filesWithErrors.length > 0
? files.value.filter(
(f) => filesWithErrors.includes(f.name) || !successes.value.includes(f.name)
)
: files.value
const responses = await Promise.all(
filesToUpload.map(async (file) => {
const { error } = await supabase.storage
.from(bucketName)
.upload(path ? `${path}/${file.name}` : file.name, file, {
cacheControl: cacheControl.toString(),
upsert,
})
return error
? { name: file.name, message: error.message }
: { name: file.name, message: undefined }
})
)
errors.value = responses.filter(
(r): r is { name: string; message: string } => r.message !== undefined
)
const successful = responses.filter((r) => !r.message).map((r) => r.name)
successes.value = Array.from(new Set([...successes.value, ...successful]))
} catch (err) {
console.error('Upload failed unexpectedly:', err)
errors.value.push({
name: 'upload',
message: 'An unexpected error occurred during upload.',
})
} finally {
loading.value = false
}
}
watch(
() => files.value.length,
() => {
if (!files.value.length) {
errors.value = []
successes.value = []
}
if (files.value.length > maxFiles) {
errors.value.push({
name: 'files',
message: `You may upload up to ${maxFiles} files`,
})
}
}
)
watch(
files,
(newFiles, oldFiles) => {
const newPreviews = new Set(newFiles.map((f) => f.preview))
oldFiles.forEach((file) => {
if (file.preview && !newPreviews.has(file.preview)) {
URL.revokeObjectURL(file.preview)
}
})
},
{ deep: true }
)
onUnmounted(() => {
files.value.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview)
}
})
})
return {
dropZoneRef,
isOverDropZone,
files,
setFiles: (v: FileWithPreview[]) => (files.value = v),
errors,
setErrors: (v: { name: string; message: string }[]) => (errors.value = v),
successes,
isSuccess,
loading,
onUpload,
maxFileSize,
maxFiles,
allowedMimeTypes,
}
}