{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "title": "File Dropzone",
  "description": "Drag-and-drop multi-file uploader with size and type validation, built on react-dropzone.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.48.0",
    "react-dropzone@^14.2.9"
  ],
  "registryDependencies": [
    "https://rdf.dev.360online.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/rapid/ui/file-dropzone/file-dropzone.tsx",
      "content": "'use client'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { CloudUploadIcon, FileIcon, ImageUpIcon, Trash2Icon } from 'lucide-react'\nimport * as React from 'react'\nimport { useDropzone } from 'react-dropzone'\nimport { Button } from '@/components/ui/button'\n// NOTE: UI strings are hardcoded in English. This is a template component — wire it to your app's i18n when adopting it.\nimport { useFile } from '@/hooks/use-file'\nimport { cn } from '@/lib/utils'\n\nconst fileDropzoneVariants = cva('w-full space-y-4', {\n\tvariants: {\n\t\tsize: {\n\t\t\tsm: '',\n\t\t\tdefault: '',\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tsize: 'default',\n\t},\n})\n\nconst fileDropzoneIconVariants = cva('text-muted-foreground transition-colors', {\n\tvariants: {\n\t\tsize: {\n\t\t\tsm: 'size-8',\n\t\t\tdefault: 'size-10',\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tsize: 'default',\n\t},\n})\n\nconst fileDropzoneTextVariants = cva('font-medium text-primary', {\n\tvariants: {\n\t\tsize: {\n\t\t\tsm: 'text-sm',\n\t\t\tdefault: 'text-base',\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tsize: 'default',\n\t},\n})\n\nexport type FileDropzoneIcon = 'cloud-upload' | 'image-upload'\n\ninterface Variants\n\textends VariantProps<typeof fileDropzoneVariants>,\n\t\tVariantProps<typeof fileDropzoneIconVariants>,\n\t\tVariantProps<typeof fileDropzoneTextVariants> {}\n\ninterface FileDropzoneProps extends Variants {\n\tvalue: File[]\n\tonChange: (files: File[]) => void\n\tmaxFiles?: number\n\tmaxFileSize?: number\n\tclassName?: string\n\ticon?: FileDropzoneIcon\n\t/** Accessible name for surrounding landmark region */\n\tregionLabel?: string\n\t/** Accessible label for the interactive drop area */\n\tariaLabel?: string\n\t/**\n\t * Ids of the elements that name the drop area, usually the field's label (which carries\n\t * \"required\"). Takes precedence over `ariaLabel`.\n\t */\n\t'aria-labelledby'?: string\n\t/** Extra ids describing the drop area, e.g. the form's help text and error message */\n\t'aria-describedby'?: string\n\t/** Marks the drop area invalid from outside, e.g. a required field left empty */\n\t'aria-invalid'?: React.AriaAttributes['aria-invalid']\n\t/** Formatter for remove-button aria-label */\n\tgetRemoveFileAriaLabel?: (fileName: string) => string\n\t/** If true, the drop area will fill its container vertically */\n\tfullHeight?: boolean\n\t/** Array of accepted file extensions (e.g., ['.pdf', '.jpg', '.png']) */\n\tacceptedFileTypes?: string[]\n}\n\n/**\n * Mapping of file extensions to MIME types for react-dropzone accept format\n */\nconst EXTENSION_TO_MIME: Record<string, string> = {\n\t// Documents\n\t'.uf': 'application/octet-stream',\n\t'.pdf': 'application/pdf',\n\t'.doc': 'application/msword',\n\t'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n\t'.docm': 'application/vnd.ms-word.document.macroEnabled.12',\n\t'.xls': 'application/vnd.ms-excel',\n\t'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n\t'.xlsb': 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n\t'.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12',\n\t'.ppt': 'application/vnd.ms-powerpoint',\n\t'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n\t'.pptm': 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',\n\t'.ppsx': 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',\n\t'.mpp': 'application/vnd.ms-project',\n\t'.txt': 'text/plain',\n\t'.rtf': 'application/rtf',\n\t'.htm': 'text/html',\n\t'.html': 'text/html',\n\t'.xml': 'application/xml',\n\t'.eml': 'message/rfc822',\n\t'.mht': 'message/rfc822',\n\t'.msg': 'application/vnd.ms-outlook',\n\t'.one': 'application/onenote',\n\t'.xps': 'application/vnd.ms-xpsdocument',\n\t'.pub': 'application/x-mspublisher',\n\t'.odt': 'application/vnd.oasis.opendocument.text',\n\t'.ods': 'application/vnd.oasis.opendocument.spreadsheet',\n\t'.odg': 'application/vnd.oasis.opendocument.graphics',\n\t'.odp': 'application/vnd.oasis.opendocument.presentation',\n\t// Images\n\t'.jpg': 'image/jpeg',\n\t'.jpeg': 'image/jpeg',\n\t'.png': 'image/png',\n\t'.gif': 'image/gif',\n\t'.bmp': 'image/bmp',\n\t'.tif': 'image/tiff',\n\t'.tiff': 'image/tiff',\n\t// Media\n\t'.mpeg-2': 'video/mpeg',\n\t'.mp3': 'audio/mpeg',\n\t'.wav': 'audio/wav',\n\t'.mp4': 'video/mp4',\n\t'.mov': 'video/quicktime',\n\t'.wmv': 'video/x-ms-wmv',\n\t// CAD & GIS\n\t'.dwg': 'image/vnd.dwg',\n\t'.dwf': 'model/vnd.dwf',\n\t'.ifc': 'model/ifc',\n\t'.sosi': 'application/octet-stream',\n\t'.sos': 'application/octet-stream',\n\t'.kof': 'application/octet-stream',\n\t'.vgt': 'application/octet-stream',\n\t'.gsi': 'application/octet-stream',\n\t'.gml': 'application/gml+xml',\n\t// Other\n\t'.zip': 'application/zip',\n\t'.vsd': 'application/vnd.visio',\n\t'.vsdx': 'application/vnd.visio',\n}\n\n/**\n * Converts an array of file extensions to react-dropzone accept format.\n * Groups extensions by their MIME type for proper accept configuration.\n *\n * @param extensions - Array of file extensions (e.g., ['.pdf', '.jpg'])\n * @returns Accept object for react-dropzone (e.g., { 'image/jpeg': ['.jpg', '.jpeg'] })\n */\nfunction createAcceptObject(extensions: string[]): Record<string, string[]> {\n\tconst accept: Record<string, string[]> = {}\n\n\tfor (const ext of extensions) {\n\t\tconst normalizedExt = ext.toLowerCase()\n\t\tconst mimeType = EXTENSION_TO_MIME[normalizedExt]\n\n\t\tif (mimeType) {\n\t\t\tif (!accept[mimeType]) {\n\t\t\t\taccept[mimeType] = []\n\t\t\t}\n\t\t\t// Only add extension if not already present (handles .jpg and .jpeg both mapping to image/jpeg)\n\t\t\tif (!accept[mimeType].includes(normalizedExt)) {\n\t\t\t\taccept[mimeType].push(normalizedExt)\n\t\t\t}\n\t\t} else {\n\t\t\t// For unknown extensions, use a generic MIME type with the extension\n\t\t\t// This allows the file input to still filter by extension\n\t\t\tconst genericMime = 'application/octet-stream'\n\t\t\tif (!accept[genericMime]) {\n\t\t\t\taccept[genericMime] = []\n\t\t\t}\n\t\t\tif (!accept[genericMime].includes(normalizedExt)) {\n\t\t\t\taccept[genericMime].push(normalizedExt)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn accept\n}\n\nexport function FileDropzone({\n\tvalue,\n\tonChange,\n\tmaxFiles,\n\tmaxFileSize,\n\tsize,\n\tclassName,\n\ticon = 'cloud-upload',\n\tregionLabel,\n\tariaLabel,\n\tgetRemoveFileAriaLabel,\n\tfullHeight = false,\n\tacceptedFileTypes,\n\t'aria-labelledby': ariaLabelledby,\n\t'aria-describedby': ariaDescribedby,\n\t'aria-invalid': ariaInvalid,\n}: Readonly<FileDropzoneProps>) {\n\tconst { convertMb } = useFile()\n\tconst hintId = React.useId()\n\tconst errorId = React.useId()\n\tconst [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n\n\t/**\n\t * Normalizes the value prop to always be an array of Files.\n\t *\n\t * Handles various input types:\n\t * - undefined/null -> []\n\t * - Array -> filter to only include File objects\n\t * - Single File -> [File]\n\t * - Anything else -> [] (invalid, reset to empty)\n\t */\n\tconst normalizedValue: File[] = React.useMemo(() => {\n\t\tif (!value) return []\n\n\t\t// Handle arrays - filter to only include File objects\n\t\tif (Array.isArray(value)) {\n\t\t\treturn value.filter((item): item is File => item instanceof File)\n\t\t}\n\n\t\t// Handle single File object (runtime check for cases where value might be a single File)\n\t\t// Use unknown cast to allow instanceof check since TypeScript types value as File[]\n\t\tconst singleValue = value as unknown\n\t\tif (singleValue && typeof singleValue === 'object' && singleValue instanceof File) {\n\t\t\treturn [singleValue]\n\t\t}\n\n\t\t// Fallback: invalid value type, return empty array\n\t\treturn []\n\t}, [value])\n\n\tconst onDrop = React.useCallback(\n\t\t(acceptedFiles: File[]) => {\n\t\t\tsetErrorMessage(null)\n\t\t\tconst allowedCount =\n\t\t\t\ttypeof maxFiles === 'number' ? Math.max(0, maxFiles - normalizedValue.length) : acceptedFiles.length\n\t\t\tconst trimmed = acceptedFiles.slice(0, allowedCount)\n\t\t\tonChange([...normalizedValue, ...trimmed])\n\t\t},\n\t\t[normalizedValue, onChange, maxFiles],\n\t)\n\n\tconst maxBytes = typeof maxFileSize === 'number' ? Math.round(maxFileSize * 1024 * 1024) : undefined\n\n\t// Create accept object from acceptedFileTypes if provided and has items\n\tconst accept = React.useMemo(() => {\n\t\tif (acceptedFileTypes && acceptedFileTypes.length > 0) {\n\t\t\treturn createAcceptObject(acceptedFileTypes)\n\t\t}\n\t\treturn undefined\n\t}, [acceptedFileTypes])\n\n\tconst { getRootProps, getInputProps, isDragActive } = useDropzone({\n\t\tonDrop,\n\t\tmaxFiles,\n\t\tmaxSize: maxBytes,\n\t\taccept,\n\t\tvalidator: (file) => {\n\t\t\tif (file.size === 0) {\n\t\t\t\treturn { code: 'file-empty', message: 'File is empty' }\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\t\tonDropRejected: (fileRejections) => {\n\t\t\tconst hasEmpty = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-empty'))\n\t\t\tconst hasTooMany = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'too-many-files'))\n\t\t\tconst hasTooLarge = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-too-large'))\n\t\t\tconst hasInvalidType = fileRejections.some((rej) => rej.errors.some((e) => e.code === 'file-invalid-type'))\n\n\t\t\tif (hasEmpty) {\n\t\t\t\tsetErrorMessage('File is empty. Please choose a file with content.')\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (hasInvalidType && acceptedFileTypes && acceptedFileTypes.length > 0) {\n\t\t\t\tconst formattedTypes = acceptedFileTypes.map((ext) => ext.toUpperCase().replace('.', '')).join(', ')\n\t\t\t\tsetErrorMessage(`Invalid file format. Please upload a ${formattedTypes} file.`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (hasTooLarge && typeof maxFileSize === 'number') {\n\t\t\t\tsetErrorMessage(`File is too large. Maximum size is ${convertMb(maxFileSize)}.`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (hasTooMany && typeof maxFiles === 'number') {\n\t\t\t\tsetErrorMessage(`Max uploads: ${maxFiles} files`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetErrorMessage(null)\n\t\t},\n\t})\n\n\t/**\n\t * Removes a file from the list.\n\t *\n\t * For already-uploaded files (with _attachmentId), uses the ID for comparison.\n\t * For new files, uses object reference comparison.\n\t * This ensures removal works correctly even when File objects are recreated on re-render.\n\t */\n\tconst removeFile = (fileToRemove: File) => {\n\t\tconst fileToRemoveWithId = fileToRemove as File & { _attachmentId?: string }\n\t\tconst identifier = fileToRemoveWithId._attachmentId\n\n\t\tif (identifier) {\n\t\t\t// For already-uploaded files, compare by _attachmentId\n\t\t\t// This works even when File objects are recreated on re-render\n\t\t\tconst filesWithId = normalizedValue as Array<File & { _attachmentId?: string }>\n\t\t\tonChange(filesWithId.filter((file) => file._attachmentId !== identifier))\n\t\t} else {\n\t\t\t// For new files (not yet uploaded), use object reference comparison\n\t\t\tonChange(normalizedValue.filter((file) => file !== fileToRemove))\n\t\t}\n\t}\n\n\t// Adding or removing a file only changes the list below the drop area, which a screen\n\t// reader does not notice; say the new count instead. Empty on the first render, so\n\t// files already there when the page opens are not announced.\n\tconst [countStatus, setCountStatus] = React.useState('')\n\tconst fileCount = normalizedValue.length\n\tconst previousCount = React.useRef(fileCount)\n\tReact.useEffect(() => {\n\t\tif (previousCount.current === fileCount) return\n\t\tpreviousCount.current = fileCount\n\t\tsetCountStatus(fileCount === 1 ? '1 file' : `${fileCount} files`)\n\t}, [fileCount])\n\n\tconst regionName = regionLabel ?? 'File uploader'\n\tconst dropAreaLabel = ariaLabel ?? 'Choose file or drag here'\n\n\t// Build hint text: \"PDF, JPG, CSV up to 1MB (max 3 files)\"\n\tconst hintParts: string[] = []\n\tif (acceptedFileTypes && acceptedFileTypes.length > 0) {\n\t\thintParts.push(acceptedFileTypes.map((ext) => ext.toUpperCase().replace('.', '')).join(', '))\n\t}\n\tif (typeof maxFileSize === 'number') {\n\t\thintParts.push(`up to ${convertMb(maxFileSize)}`)\n\t}\n\tconst hintText = hintParts.length > 0 ? hintParts.join(' ') : null\n\tconst maxFilesText = typeof maxFiles === 'number' ? `(max uploads ${maxFiles})` : null\n\n\treturn (\n\t\t<section\n\t\t\tdata-slot=\"file-dropzone\"\n\t\t\tclassName={cn(fileDropzoneVariants({ size, className }), 'group/file-dropzone min-w-0')}\n\t\t\taria-label={regionName}\n\t\t>\n\t\t\t<div\n\t\t\t\tdata-dragging={isDragActive || undefined}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'relative flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-input bg-primary-foreground p-6 transition-colors outline-none',\n\t\t\t\t\t!errorMessage && 'hover:border-primary hover:bg-primary/5 data-dragging:border-primary data-dragging:bg-primary/5',\n\t\t\t\t\t// The focus indicator is drawn inside the border: a dropzone usually sits in a\n\t\t\t\t\t// container with overflow:hidden, and anything painted outside the border box\n\t\t\t\t\t// (a ring, which is a box-shadow, or an outline) is clipped away there, so\n\t\t\t\t\t// keyboard users could not see where they were. The error ring is inset for the same reason.\n\t\t\t\t\t'focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring',\n\t\t\t\t\t'aria-invalid:border-destructive aria-invalid:outline-destructive aria-invalid:ring-3 aria-invalid:ring-inset aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',\n\t\t\t\t\t{ 'h-full': fullHeight },\n\t\t\t\t)}\n\t\t\t\t{...getRootProps({\n\t\t\t\t\trole: 'button',\n\t\t\t\t\t'aria-invalid': errorMessage || (ariaInvalid && ariaInvalid !== 'false') ? true : undefined,\n\t\t\t\t\t'aria-describedby': [hintId, errorMessage ? errorId : null, ariaDescribedby].filter(Boolean).join(' '),\n\t\t\t\t\t'aria-label': ariaLabelledby ? undefined : dropAreaLabel,\n\t\t\t\t\t'aria-labelledby': ariaLabelledby,\n\t\t\t\t})}\n\t\t\t>\n\t\t\t\t<div className=\"flex flex-col items-center gap-4\">\n\t\t\t\t\t<div className=\"flex flex-col items-center gap-3\">\n\t\t\t\t\t\t{icon === 'cloud-upload' && (\n\t\t\t\t\t\t\t<CloudUploadIcon\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\tfileDropzoneIconVariants({ size }),\n\t\t\t\t\t\t\t\t\terrorMessage ? 'text-destructive' : 'group-hover/file-dropzone:text-primary',\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\tfocusable=\"false\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{icon === 'image-upload' && (\n\t\t\t\t\t\t\t<ImageUpIcon\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\tfileDropzoneIconVariants({ size }),\n\t\t\t\t\t\t\t\t\terrorMessage ? 'text-destructive' : 'group-hover/file-dropzone:text-primary',\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\tstrokeWidth={1.5}\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\tfocusable=\"false\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<span className={fileDropzoneTextVariants({ size })}>\n\t\t\t\t\t\t\tChoose file <span className=\"font-normal leading-tight text-foreground\">or drag here</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{(hintText || maxFilesText) && (\n\t\t\t\t\t\t<p className=\"text-center text-sm leading-tight text-muted-foreground\" id={hintId}>\n\t\t\t\t\t\t\t{hintText}\n\t\t\t\t\t\t\t{hintText && maxFilesText && ' '}\n\t\t\t\t\t\t\t{maxFilesText}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t<input {...getInputProps()} className=\"hidden\" aria-hidden=\"true\" tabIndex={-1} />\n\t\t\t</div>\n\n\t\t\t{/* <output> has the status role: the new count is announced politely */}\n\t\t\t<output className=\"sr-only\">{countStatus}</output>\n\n\t\t\t{errorMessage && (\n\t\t\t\t<p id={errorId} role=\"alert\" aria-live=\"polite\" className=\"text-sm text-destructive\">\n\t\t\t\t\t{errorMessage}\n\t\t\t\t</p>\n\t\t\t)}\n\n\t\t\t{normalizedValue.length > 0 && (\n\t\t\t\t<div className=\"w-full overflow-hidden rounded-lg border bg-background\">\n\t\t\t\t\t<div className=\"flex items-center justify-between border-b px-3 py-2\">\n\t\t\t\t\t\t<h3 className=\"text-sm font-medium\">{`${normalizedValue.length} files`}</h3>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"overflow-y-auto max-h-48\">\n\t\t\t\t\t\t<ul className=\"divide-y divide-border\">\n\t\t\t\t\t\t\t{normalizedValue.map((file, index) => {\n\t\t\t\t\t\t\t\t// Use _attachmentId as key for already-uploaded files to ensure stable keys\n\t\t\t\t\t\t\t\t// For new files, use name + size + lastModified + index as fallback\n\t\t\t\t\t\t\t\tconst fileWithId = file as File & { _attachmentId?: string }\n\t\t\t\t\t\t\t\tconst fileKey = fileWithId._attachmentId ?? `${file.name}-${file.size}-${file.lastModified}-${index}`\n\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<li key={fileKey} className=\"flex items-center gap-2 overflow-hidden px-3 py-2 hover:bg-muted/50\">\n\t\t\t\t\t\t\t\t\t\t<FileIcon className=\"size-4 shrink-0 text-primary\" />\n\t\t\t\t\t\t\t\t\t\t<div className=\"min-w-0 flex-1 overflow-hidden\">\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-sm font-medium truncate\" title={file.name}>\n\t\t\t\t\t\t\t\t\t\t\t\t{file.name}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">{(file.size / 1024 / 1024).toFixed(2)} MB</p>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => removeFile(file)}\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\t\t\tsize=\"icon-sm\"\n\t\t\t\t\t\t\t\t\t\t\taria-label={getRemoveFileAriaLabel ? getRemoveFileAriaLabel(file.name) : `Remove ${file.name}`}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Trash2Icon aria-hidden=\"true\" focusable=\"false\" />\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</section>\n\t)\n}\n",
      "type": "registry:ui",
      "target": "components/ui/file-dropzone.tsx"
    },
    {
      "path": "hooks/use-file.ts",
      "content": "function getByteSize(file: File): string {\n\tconst bytes = file.size\n\tconst sizes = ['MB', 'GB', 'TB']\n\tconst i = Math.floor(Math.log(bytes / 1024 / 1024) / Math.log(1024))\n\n\tif (bytes === 0) return '0 MB'\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`\n\n\treturn `${(bytes / 1024 ** (i + 2)).toFixed(2)} ${sizes[i]}`\n}\n\nfunction convertMb(size: number): string {\n\tif (size >= 1000) {\n\t\treturn `${size / 1000}GB`\n\t}\n\treturn `${size}MB`\n}\n\nexport function useFile() {\n\treturn {\n\t\tgetByteSize,\n\t\tconvertMb,\n\t}\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-file.ts"
    }
  ],
  "docs": "UI strings in this component are hardcoded in English. It is a template component: wire it to your app's i18n (e.g. next-intl) after installing.",
  "type": "registry:ui"
}