{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-ring-upload",
  "title": "Progress Ring Upload",
  "description": "One ring for the whole batch: a counting percentage, per-file ticks and a self-drawing check.",
  "dependencies": [
    "@uploadcn/core@^0.1.0",
    "@uploadcn/react@^0.1.0",
    "cn",
    "motion"
  ],
  "registryDependencies": [
    "@uploadcn/upload"
  ],
  "files": [
    {
      "path": "registry/default/blocks/progress-ring-upload.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  formatBytes,\n  formatDuration,\n  type UploadAdapter,\n  type Uploader,\n  type UploadItem as UploadItemData,\n  type ValidationOptions,\n} from \"@uploadcn/core\"\nimport {\n  useUploadContext,\n  useUploadProgress,\n  type UploadRootProps,\n} from \"@uploadcn/react\"\nimport { cn } from \"cn\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\"\n\nimport { IconPlaceholder } from \"@/components/icon-placeholder\"\nimport { Upload, UploadDropzone } from \"@/registry/default/ui/upload\"\n\ninterface ProgressRingUploadProps<TResult> extends Pick<\n  ValidationOptions,\n  \"accept\" | \"maxSize\" | \"maxFiles\"\n> {\n  adapter?: UploadAdapter<TResult>\n  /** Use an uploader you created with `useUploader` instead of options. */\n  uploader?: Uploader<TResult>\n  onSuccess?: (item: UploadItemData<TResult>) => void\n  className?: string\n}\n\nconst SIZE = 176\nconst STROKE = 10\nconst RADIUS = (SIZE - STROKE) / 2\nconst CIRCUMFERENCE = 2 * Math.PI * RADIUS\n\n/**\n * One big ring for the whole batch: the percentage counts up in the\n * middle, ticks mark each file, and a check draws itself when everything\n * is done.\n */\nfunction ProgressRingUpload<TResult = unknown>({\n  adapter,\n  uploader,\n  className,\n  ...options\n}: ProgressRingUploadProps<TResult>) {\n  return (\n    <Upload\n      {...((uploader\n        ? { uploader }\n        : {\n            adapter,\n            ...options,\n          }) as UploadRootProps<TResult>)}\n      className={cn(\"items-center\", className)}\n    >\n      <UploadDropzone\n        variant=\"muted\"\n        className=\"size-56 rounded-full bg-transparent p-0 hover:bg-transparent data-dragging:bg-transparent data-dragging:ring-0\"\n      >\n        <Ring />\n      </UploadDropzone>\n      <RingCaption />\n    </Upload>\n  )\n}\n\nfunction Ring() {\n  const { uploader } = useUploadContext()\n  const summary = useUploadProgress(uploader)\n  const reduceMotion = useReducedMotion()\n  const percent = summary.isComplete ? 100 : summary.percent\n  const spring = useSpring(percent, { stiffness: 90, damping: 20 })\n  const offset = useTransform(\n    spring,\n    (value) => CIRCUMFERENCE * (1 - value / 100)\n  )\n  const label = useTransform(spring, (value) => `${Math.round(value)}`)\n\n  React.useEffect(() => {\n    if (reduceMotion) spring.jump(percent)\n    else spring.set(percent)\n  }, [spring, percent, reduceMotion])\n\n  const idle = summary.total === 0\n  return (\n    <div className=\"relative flex items-center justify-center transition-transform duration-300 group-hover/upload-dropzone:scale-105 group-data-dragging/upload-dropzone:scale-110 motion-reduce:transition-none\">\n      <svg width={SIZE} height={SIZE} className=\"-rotate-90\" aria-hidden>\n        <circle\n          cx={SIZE / 2}\n          cy={SIZE / 2}\n          r={RADIUS}\n          fill=\"none\"\n          strokeWidth={STROKE}\n          className=\"stroke-muted transition-colors group-data-dragging/upload-dropzone:stroke-primary/20\"\n        />\n        {/* One tick per file */}\n        {Array.from({ length: Math.min(summary.total, 24) }, (_, index) => {\n          const angle = (index / Math.min(summary.total, 24)) * Math.PI * 2\n          const r1 = RADIUS - STROKE\n          const r2 = RADIUS - STROKE - 4\n          return (\n            <line\n              key={index}\n              x1={SIZE / 2 + r1 * Math.cos(angle)}\n              y1={SIZE / 2 + r1 * Math.sin(angle)}\n              x2={SIZE / 2 + r2 * Math.cos(angle)}\n              y2={SIZE / 2 + r2 * Math.sin(angle)}\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n              className=\"stroke-muted-foreground/40\"\n            />\n          )\n        })}\n        <motion.circle\n          cx={SIZE / 2}\n          cy={SIZE / 2}\n          r={RADIUS}\n          fill=\"none\"\n          strokeWidth={STROKE}\n          strokeLinecap=\"round\"\n          strokeDasharray={CIRCUMFERENCE}\n          style={{ strokeDashoffset: offset }}\n          className={cn(\n            \"stroke-primary\",\n            summary.hasErrors && !summary.isUploading && \"stroke-destructive\"\n          )}\n        />\n      </svg>\n      <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {idle ? (\n            <motion.span\n              key=\"idle\"\n              initial={{ opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.8 }}\n              className=\"flex flex-col items-center gap-1 text-muted-foreground\"\n            >\n              <IconPlaceholder\n                lucide=\"PlusIcon\"\n                tabler=\"IconPlus\"\n                hugeicons=\"Add01Icon\"\n                phosphor=\"PlusIcon\"\n                remixicon=\"RiAddLine\"\n                className=\"size-7\"\n              />\n              <span className=\"text-xs font-medium\">Add files</span>\n            </motion.span>\n          ) : summary.isComplete ? (\n            <motion.svg\n              key=\"done\"\n              viewBox=\"0 0 24 24\"\n              className=\"size-14 text-primary\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              role=\"img\"\n              aria-label=\"All files uploaded\"\n            >\n              <motion.path\n                d=\"M5 12.5l4.5 4.5L19 7.5\"\n                initial={{ pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 0.45, ease: \"easeOut\" }}\n              />\n            </motion.svg>\n          ) : (\n            <motion.span\n              key=\"progress\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              className=\"flex items-baseline text-4xl font-semibold tracking-tight tabular-nums\"\n            >\n              <motion.span>{label}</motion.span>\n              <span className=\"text-lg text-muted-foreground\">%</span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  )\n}\n\nfunction RingCaption() {\n  const { uploader } = useUploadContext()\n  const summary = useUploadProgress(uploader)\n  if (summary.total === 0) {\n    return (\n      <p className=\"text-center text-sm text-muted-foreground\">\n        Drop files on the ring, or click it\n      </p>\n    )\n  }\n  const eta = formatDuration(summary.eta)\n  return (\n    <p\n      className=\"text-center text-sm text-muted-foreground tabular-nums\"\n      aria-live=\"polite\"\n    >\n      {summary.counts.success} of {summary.total} files ·{\" \"}\n      {formatBytes(summary.loaded)} of {formatBytes(summary.size)}\n      {summary.isUploading && eta ? ` · ${eta} left` : \"\"}\n    </p>\n  )\n}\n\nexport { ProgressRingUpload, type ProgressRingUploadProps }\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "upload",
    "animated"
  ],
  "type": "registry:block"
}