{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone-file-viewer-example",
  "title": "Uploader + Viewer",
  "description": "One upload surface that becomes a file viewer, with the uploaded blob rendered by FileViewer.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@retab/dropzone",
    "@retab/file-viewer",
    "@retab/file-size-format",
    "@retab/file-thumbnail"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/blocks/dropzone-file-viewer-example.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { type DropzoneExampleProps } from \"./dropzone-example-shared\";\nimport { FileIntakeViewer } from \"./dropzone-uploader-viewer\";\n\nexport function DropzoneFileViewerExample({ className }: DropzoneExampleProps) {\n  return <FileIntakeViewer className={cn(\"h-[34rem]\", className)} />;\n}\n",
      "type": "registry:component",
      "target": "@components/blocks/dropzone-file-viewer-example.tsx"
    },
    {
      "path": "registry/new-york-v4/blocks/dropzone-uploader-viewer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type {\n  DropzoneFileItem,\n  DropzoneIntake,\n} from \"@/components/ui/dropzone\";\nimport { ViewerBody } from \"@/components/ui/viewer\";\n\nimport {\n  FileIntakeViewerDropTarget,\n  FileIntakeViewerHeader,\n  FileIntakeViewerProvider,\n  FileIntakeViewerRoot,\n  FileIntakeViewerSidebar,\n  FileIntakeViewerSurface,\n} from \"./dropzone-uploader-viewer-parts\";\n\nexport type FileIntakeViewerProps = {\n  accept?: string;\n  className?: string;\n  defaultFiles?: DropzoneFileItem[];\n  disabled?: boolean;\n  files?: DropzoneFileItem[];\n  maxSize?: number;\n  onFilesChange?: (files: DropzoneFileItem[]) => void;\n  onIntake?: (intake: DropzoneIntake) => void;\n};\n\nexport function FileIntakeViewer({\n  accept,\n  className,\n  defaultFiles,\n  disabled,\n  files,\n  maxSize,\n  onFilesChange,\n  onIntake,\n}: FileIntakeViewerProps) {\n  return (\n    <FileIntakeViewerProvider\n      accept={accept}\n      defaultFiles={defaultFiles}\n      disabled={disabled}\n      files={files}\n      maxSize={maxSize}\n      onFilesChange={onFilesChange}\n      onIntake={onIntake}\n    >\n      <FileIntakeViewerDropTarget>\n        <FileIntakeViewerRoot className={className}>\n          <FileIntakeViewerHeader />\n          <ViewerBody className=\"flex-col md:flex-row\">\n            <FileIntakeViewerSidebar />\n            <FileIntakeViewerSurface />\n          </ViewerBody>\n        </FileIntakeViewerRoot>\n      </FileIntakeViewerDropTarget>\n    </FileIntakeViewerProvider>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/blocks/dropzone-uploader-viewer.tsx"
    },
    {
      "path": "registry/new-york-v4/blocks/dropzone-uploader-viewer-parts.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Upload, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { blobSource } from \"@/lib/viewer-resource\";\nimport type { BlobViewerSource } from \"@/lib/viewer-source\";\nimport {\n  useDropzone,\n  type DropzoneFileItem,\n  type DropzoneFileRejection,\n  type DropzoneIntake,\n  type UseDropzoneReturn,\n} from \"@/components/ui/dropzone\";\nimport { formatFileSize } from \"@/components/ui/file-size-format\";\nimport { FileThumbnail } from \"@/components/ui/file-thumbnail\";\nimport { FileViewerPreview } from \"@/components/ui/file-viewer\";\nimport {\n  ViewerHeader,\n  ViewerRoot,\n  ViewerSidebar,\n  ViewerSidebarTrigger,\n  ViewerSurface,\n} from \"@/components/ui/viewer\";\n\nconst DEFAULT_FILE_INTAKE_VIEWER_ACCEPT =\n  \".pdf,.png,.jpg,.jpeg,.csv,.txt,.md,.json,application/pdf,image/*,text/*,text/csv,application/json\";\n\nexport interface FileIntakeViewerProviderProps {\n  accept?: string;\n  defaultFiles?: DropzoneFileItem[];\n  disabled?: boolean;\n  files?: DropzoneFileItem[];\n  maxSize?: number;\n  onFilesChange?: (files: DropzoneFileItem[]) => void;\n  onIntake?: (intake: DropzoneIntake) => void;\n  children: React.ReactNode;\n}\n\ntype FileIntakeViewerModel = {\n  canClear: boolean;\n  hasFile: boolean;\n  isDragging: boolean;\n  rejection: FileIntakeViewerRejection | null;\n  selectedFile: DropzoneFileItem | null;\n  selectedFileSummary: FileIntakeSummary | null;\n  viewerSource: BlobViewerSource | null;\n};\n\ntype FileIntakeSummary = {\n  file: File;\n  fileName: string;\n  fileSizeLabel: string;\n  fileTypeLabel: string;\n};\n\nexport type FileIntakeViewerRejection = {\n  title: string;\n  description: string;\n};\n\ntype FileIntakeViewerActions = {\n  clearFile: () => void;\n  getRootDropProps: UseDropzoneReturn[\"getRootProps\"];\n  getFileInputProps: UseDropzoneReturn[\"getInputProps\"];\n  getUploadButtonProps: UseDropzoneReturn[\"getTriggerProps\"];\n  getEmptySurfaceProps: UseDropzoneReturn[\"getTriggerProps\"];\n};\n\ntype FileIntakeViewerContextValue = {\n  actions: FileIntakeViewerActions;\n  model: FileIntakeViewerModel;\n};\n\ntype FileIntakeViewerDropTargetState = {\n  getFileInputProps: FileIntakeViewerActions[\"getFileInputProps\"];\n  getRootDropProps: FileIntakeViewerActions[\"getRootDropProps\"];\n  isDragging: boolean;\n};\n\ntype FileIntakeViewerHeaderState = {\n  canClear: boolean;\n  clearFile: FileIntakeViewerActions[\"clearFile\"];\n  getUploadButtonProps: FileIntakeViewerActions[\"getUploadButtonProps\"];\n  selectedFileSummary: FileIntakeSummary | null;\n};\n\ntype FileIntakeViewerSidebarState = {\n  getUploadButtonProps: FileIntakeViewerActions[\"getUploadButtonProps\"];\n  selectedFileSummary: FileIntakeSummary | null;\n};\n\nexport type FileIntakeViewerSurfaceState = {\n  getEmptySurfaceProps: UseDropzoneReturn[\"getTriggerProps\"];\n  rejection: FileIntakeViewerRejection | null;\n  viewerSource: BlobViewerSource | null;\n};\n\nconst FileIntakeViewerContext =\n  React.createContext<FileIntakeViewerContextValue | null>(null);\n\nfunction useFileIntakeViewerContext(): FileIntakeViewerContextValue {\n  const context = React.useContext(FileIntakeViewerContext);\n  if (!context) {\n    throw new Error(\n      \"useFileIntakeViewer must be used within FileIntakeViewerProvider.\",\n    );\n  }\n  return context;\n}\n\nfunction useFileIntakeViewerDropTarget(): FileIntakeViewerDropTargetState {\n  const { actions, model } = useFileIntakeViewerContext();\n  return {\n    getFileInputProps: actions.getFileInputProps,\n    getRootDropProps: actions.getRootDropProps,\n    isDragging: model.isDragging,\n  };\n}\n\nfunction useFileIntakeViewerHeader(): FileIntakeViewerHeaderState {\n  const { actions, model } = useFileIntakeViewerContext();\n  return {\n    canClear: model.canClear,\n    clearFile: actions.clearFile,\n    getUploadButtonProps: actions.getUploadButtonProps,\n    selectedFileSummary: model.selectedFileSummary,\n  };\n}\n\nfunction useFileIntakeViewerSidebar(): FileIntakeViewerSidebarState {\n  const { actions, model } = useFileIntakeViewerContext();\n  return {\n    getUploadButtonProps: actions.getUploadButtonProps,\n    selectedFileSummary: model.selectedFileSummary,\n  };\n}\n\nexport function useFileIntakeViewerSurface(): FileIntakeViewerSurfaceState {\n  const { actions, model } = useFileIntakeViewerContext();\n  return {\n    getEmptySurfaceProps: actions.getEmptySurfaceProps,\n    rejection: model.rejection,\n    viewerSource: model.viewerSource,\n  };\n}\n\nexport function FileIntakeViewerProvider({\n  accept = DEFAULT_FILE_INTAKE_VIEWER_ACCEPT,\n  defaultFiles,\n  disabled,\n  files,\n  maxSize,\n  onFilesChange,\n  onIntake,\n  children,\n}: FileIntakeViewerProviderProps) {\n  const dropzone = useDropzone({\n    accept,\n    defaultFiles,\n    disabled,\n    files,\n    maxFiles: 1,\n    maxSize,\n    multiple: false,\n    onFilesChange,\n    onIntake,\n  });\n  const { clearFiles, getInputProps, getRootProps, getTriggerProps } = dropzone;\n  const model = React.useMemo<FileIntakeViewerModel>(\n    () =>\n      createFileIntakeViewerModel({\n        files: dropzone.files,\n        isDisabled: dropzone.isDisabled,\n        isDragging: dropzone.isDragging,\n        lastIntake: dropzone.lastIntake,\n      }),\n    [\n      dropzone.files,\n      dropzone.isDisabled,\n      dropzone.isDragging,\n      dropzone.lastIntake,\n    ],\n  );\n  const actions = React.useMemo<FileIntakeViewerActions>(\n    () => ({\n      clearFile: clearFiles,\n      getRootDropProps: getRootProps,\n      getFileInputProps: getInputProps,\n      getUploadButtonProps: (props) =>\n        getTriggerProps({ ...props, native: true }),\n      getEmptySurfaceProps: getTriggerProps,\n    }),\n    [clearFiles, getInputProps, getRootProps, getTriggerProps],\n  );\n  const value = React.useMemo<FileIntakeViewerContextValue>(\n    () => ({\n      actions,\n      model,\n    }),\n    [actions, model],\n  );\n\n  return (\n    <FileIntakeViewerContext.Provider value={value}>\n      {children}\n    </FileIntakeViewerContext.Provider>\n  );\n}\n\nexport function FileIntakeViewerDropTarget({\n  children,\n  className,\n}: {\n  children: React.ReactNode;\n  className?: string;\n}) {\n  const { getFileInputProps, getRootDropProps } =\n    useFileIntakeViewerDropTarget();\n\n  return (\n    <section\n      {...getRootDropProps({\n        className: cn(\"group/file-intake-drop contents\", className),\n      })}\n    >\n      <input {...getFileInputProps({ className: \"hidden\" })} />\n      {children}\n    </section>\n  );\n}\n\nexport function FileIntakeViewerRoot({\n  children,\n  className,\n}: {\n  children: React.ReactNode;\n  className?: string;\n}) {\n  return (\n    <ViewerRoot\n      defaultOpen\n      mode=\"inline\"\n      className={cn(\n        \"bg-background text-foreground min-h-[30rem] rounded-lg border transition-colors\",\n        \"group-data-[dragging]/file-intake-drop:border-foreground/40 group-data-[dragging]/file-intake-drop:bg-accent/35\",\n        className,\n      )}\n    >\n      {children}\n    </ViewerRoot>\n  );\n}\n\nexport function FileIntakeViewerHeader() {\n  const { canClear, clearFile, getUploadButtonProps, selectedFileSummary } =\n    useFileIntakeViewerHeader();\n\n  return (\n    <ViewerHeader className=\"flex flex-wrap items-center justify-between gap-3 px-4 py-3\">\n      <div className=\"flex min-w-0 items-center gap-2\">\n        <ViewerSidebarTrigger />\n        <div className=\"min-w-0\">\n          <div className=\"text-sm font-medium\">File preview</div>\n          {selectedFileSummary ? (\n            <div className=\"text-muted-foreground mt-1 truncate text-xs\">\n              {selectedFileSummary.fileName}\n            </div>\n          ) : null}\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {selectedFileSummary && canClear ? (\n          <button\n            type=\"button\"\n            className=\"bg-background text-muted-foreground hover:bg-muted hover:text-foreground grid size-8 place-items-center rounded-md border\"\n            aria-label={`Remove ${selectedFileSummary.fileName}`}\n            onClick={clearFile}\n          >\n            <X className=\"size-4\" aria-hidden />\n          </button>\n        ) : null}\n        <button\n          {...getUploadButtonProps({\n            \"aria-label\": selectedFileSummary\n              ? `Replace ${selectedFileSummary.fileName}`\n              : \"Upload file\",\n            className:\n              \"inline-flex h-8 cursor-pointer items-center gap-2 rounded-md border bg-background px-3 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/24\",\n          })}\n        >\n          <Upload className=\"size-3.5\" aria-hidden />\n          {selectedFileSummary ? \"Replace\" : \"Upload file\"}\n        </button>\n      </div>\n    </ViewerHeader>\n  );\n}\n\nexport function FileIntakeViewerSidebar() {\n  const { getUploadButtonProps, selectedFileSummary } =\n    useFileIntakeViewerSidebar();\n\n  return (\n    <ViewerSidebar\n      aria-label=\"Selected file\"\n      width=\"12rem\"\n      className=\"bg-background border-b p-3 md:border-r md:border-b-0\"\n    >\n      {selectedFileSummary ? (\n        <FileIntakeViewerFileCard fileSummary={selectedFileSummary} />\n      ) : (\n        <FileIntakeViewerNoFile />\n      )}\n      {!selectedFileSummary ? (\n        <button\n          {...getUploadButtonProps({\n            \"aria-label\": \"Upload file\",\n            className:\n              \"mt-4 inline-flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/24\",\n          })}\n        >\n          <Upload className=\"size-3.5\" aria-hidden />\n          Upload file\n        </button>\n      ) : null}\n    </ViewerSidebar>\n  );\n}\n\nexport function FileIntakeViewerSurface() {\n  const { getEmptySurfaceProps, rejection, viewerSource } =\n    useFileIntakeViewerSurface();\n\n  return (\n    <ViewerSurface className=\"min-h-[24rem]\">\n      {viewerSource ? (\n        <FileViewerPreview\n          source={viewerSource}\n          className=\"size-full min-h-0\"\n        />\n      ) : (\n        <FileIntakeViewerEmptyState\n          getEmptySurfaceProps={getEmptySurfaceProps}\n          rejection={rejection}\n        />\n      )}\n    </ViewerSurface>\n  );\n}\n\nfunction FileIntakeViewerFileCard({\n  fileSummary,\n}: {\n  fileSummary: FileIntakeSummary;\n}) {\n  return (\n    <div className=\"space-y-3\">\n      <FileThumbnail\n        file={fileSummary.file}\n        thumbnailShape=\"square\"\n        thumbnailSize=\"xl\"\n        className=\"bg-background shadow-sm\"\n      />\n      <div className=\"min-w-0\">\n        <div className=\"line-clamp-3 text-sm leading-snug font-medium break-words\">\n          {fileSummary.fileName}\n        </div>\n        <div className=\"text-muted-foreground mt-1 text-xs\">\n          {fileSummary.fileSizeLabel}\n        </div>\n      </div>\n      <div className=\"bg-background text-muted-foreground rounded-md border p-2 text-xs\">\n        {fileSummary.fileTypeLabel}\n      </div>\n    </div>\n  );\n}\n\nfunction FileIntakeViewerNoFile() {\n  return (\n    <div>\n      <div className=\"text-sm font-medium\">No file selected</div>\n      <div className=\"text-muted-foreground mt-1 text-xs\">\n        PDF, image, CSV, text, Markdown, or JSON.\n      </div>\n    </div>\n  );\n}\n\nfunction FileIntakeViewerEmptyState({\n  getEmptySurfaceProps,\n  rejection,\n}: {\n  getEmptySurfaceProps: FileIntakeViewerActions[\"getEmptySurfaceProps\"];\n  rejection: FileIntakeViewerRejection | null;\n}) {\n  return (\n    <div\n      {...getEmptySurfaceProps({\n        \"aria-label\": \"Upload file\",\n        className:\n          \"grid h-full min-h-[26rem] cursor-pointer place-items-center rounded-md border border-dashed bg-background p-6 text-center outline-none transition-colors hover:bg-muted/30 focus-visible:ring-[3px] focus-visible:ring-ring/24\",\n      })}\n    >\n      <div>\n        <div className=\"bg-muted/30 text-muted-foreground mx-auto grid size-12 place-items-center rounded-md border\">\n          <Upload className=\"size-5\" aria-hidden />\n        </div>\n        <div className=\"mt-4 text-sm font-medium\">Upload file</div>\n        <div className=\"text-muted-foreground mt-1 text-xs\">\n          Drop a file here to open it in the viewer.\n        </div>\n        {rejection ? (\n          <div className=\"border-destructive/30 bg-destructive/5 text-destructive mt-4 rounded-md border px-3 py-2 text-xs\">\n            <div className=\"font-medium\">{rejection.title}</div>\n            <div className=\"text-destructive/80 mt-1\">\n              {rejection.description}\n            </div>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nfunction getSelectedFileIntakeFile(files: DropzoneFileItem[]) {\n  return files[0] ?? null;\n}\n\nfunction createFileIntakeSummary(\n  fileItem: DropzoneFileItem | null,\n): FileIntakeSummary | null {\n  if (!fileItem) return null;\n\n  return {\n    file: fileItem.file,\n    fileName: fileItem.file.name,\n    fileSizeLabel: formatFileSize(fileItem.file.size),\n    fileTypeLabel: fileItem.file.type || \"Unknown type\",\n  };\n}\n\nfunction createFileIntakeViewerSource(\n  fileItem: DropzoneFileItem,\n): BlobViewerSource {\n  return blobSource(fileItem.file, {\n    fileName: fileItem.file.name,\n    identityKey: fileItem.id,\n    mimeType: fileItem.file.type || undefined,\n  });\n}\n\nfunction createFileIntakeViewerModel(\n  dropzone: Pick<\n    UseDropzoneReturn,\n    \"files\" | \"isDragging\" | \"isDisabled\" | \"lastIntake\"\n  >,\n): FileIntakeViewerModel {\n  const selectedFile = getSelectedFileIntakeFile(dropzone.files);\n  const selectedFileSummary = createFileIntakeSummary(selectedFile);\n  const viewerSource = selectedFile\n    ? createFileIntakeViewerSource(selectedFile)\n    : null;\n\n  return {\n    canClear: selectedFile !== null && !dropzone.isDisabled,\n    hasFile: selectedFile !== null,\n    isDragging: dropzone.isDragging,\n    rejection: createFileIntakeViewerRejection(dropzone.lastIntake),\n    selectedFile,\n    selectedFileSummary,\n    viewerSource,\n  };\n}\n\nfunction createFileIntakeViewerRejection(\n  intake: DropzoneIntake,\n): FileIntakeViewerRejection | null {\n  if (intake.acceptedFiles.length > 0 || intake.fileRejections.length === 0) {\n    return null;\n  }\n\n  return describeFileIntakeRejection(intake.fileRejections[0]);\n}\n\nfunction describeFileIntakeRejection(\n  rejection: DropzoneFileRejection,\n): FileIntakeViewerRejection {\n  if (rejection.reason === \"file-invalid-type\") {\n    return {\n      title: \"Unsupported file type\",\n      description: `${rejection.file.name} cannot be opened here.`,\n    };\n  }\n\n  if (rejection.reason === \"file-too-large\") {\n    return {\n      title: \"File is too large\",\n      description: `${rejection.file.name} must be ${formatFileSize(\n        rejection.maxSize,\n      )} or smaller.`,\n    };\n  }\n\n  return {\n    title: \"Only one file can be previewed\",\n    description: `${rejection.file.name} was not added.`,\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/blocks/dropzone-uploader-viewer-parts.tsx"
    },
    {
      "path": "registry/new-york-v4/blocks/dropzone-example-shared.tsx",
      "content": "\"use client\";\n\nimport { FileText, X } from \"lucide-react\";\n\nimport type {\n  DropzoneFileItem,\n  DropzoneFileRejection,\n} from \"@/components/ui/dropzone\";\nimport { formatFileSize } from \"@/components/ui/file-size-format\";\n\nexport type DropzoneExampleProps = {\n  className?: string;\n};\n\nexport function InlineFileRows({\n  files,\n  onRemove,\n}: {\n  files: DropzoneFileItem[];\n  onRemove: (fileId: string) => void;\n}) {\n  if (files.length === 0) return null;\n\n  return (\n    <div className=\"mt-3 space-y-1\">\n      {files.map((item) => (\n        <div\n          key={item.id}\n          className=\"bg-background flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-xs\"\n        >\n          <FileText className=\"text-muted-foreground size-3.5 shrink-0\" />\n          <div className=\"min-w-0 flex-1 truncate\">{item.file.name}</div>\n          <div className=\"text-muted-foreground shrink-0\">\n            {formatFileSize(item.file.size)}\n          </div>\n          <button\n            type=\"button\"\n            aria-label={`Remove ${item.file.name}`}\n            className=\"text-muted-foreground hover:bg-muted hover:text-foreground grid size-5 shrink-0 place-items-center rounded-[4px]\"\n            onClick={() => onRemove(item.id)}\n          >\n            <X className=\"size-3\" aria-hidden />\n          </button>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nexport function RejectionRows({\n  rejections,\n}: {\n  rejections: DropzoneFileRejection[];\n}) {\n  if (rejections.length === 0) return null;\n\n  return (\n    <div className=\"text-destructive mt-3 space-y-1 text-xs\">\n      {rejections.map((rejection) => (\n        <div key={`${rejection.file.name}-${rejection.reason}`}>\n          {rejection.file.name}: {getDropzoneRejectionMessage(rejection)}\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction getDropzoneRejectionMessage(rejection: DropzoneFileRejection): string {\n  if (rejection.reason === \"file-invalid-type\") {\n    return \"This file type is not supported here.\";\n  }\n  if (rejection.reason === \"file-too-large\") {\n    return `File must be ${formatFileSize(rejection.maxSize)} or smaller.`;\n  }\n  if (rejection.reason === \"custom\") {\n    return rejection.code;\n  }\n  return rejection.maxFiles === 1\n    ? \"Only one file can be selected.\"\n    : `Only ${rejection.maxFiles} files can be selected.`;\n}\n",
      "type": "registry:file",
      "target": "registry/new-york-v4/blocks/dropzone-example-shared.tsx"
    }
  ],
  "categories": [
    "dropzone"
  ],
  "type": "registry:block"
}