Files
supabase/apps/ui-library/public/r/dropzone-nuxtjs.json
Ivan Vasilov 69ce915a9e chore: Rename SUPABASE_PUBLISHABLE_OR_ANON_KEY to SUPABASE_PUBLISHABLE_KEY for all blocks (#42652)
This PR renames all `SUPABASE_PUBLISHABLE_OR_ANON_KEY` env vars into
`SUPABASE_PUBLISHABLE_KEY` to make the new API keys default. This is in
coordination with the rest of the docs.

I've also cleaned up the `blocks/vue` package from unused files.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Breaking Changes**
* Public environment variable names renamed from PUBLISHABLE_OR_ANON_KEY
→ PUBLISHABLE_KEY across all framework integrations; update your
environment configs.

* **Documentation**
* All framework guides, .env examples and registry docs updated to use
the new variable names.

* **Chores**
* Cleaned up UI registry/templates: some example Vue registry items and
autogenerated registry artifacts were removed or simplified.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-11 10:23:16 +01:00

41 lines
14 KiB
JSON

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