{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone-nuxtjs",
  "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"
    }
  ],
  "type": "registry:block"
}