{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-failed",
  "type": "registry:component",
  "title": "Upload Failed",
  "description": "A batch upload where each failed file says exactly why, too large, wrong type or a dropped connection, next to the fix for it, and a retry picks up where the upload stopped.",
  "categories": [
    "component",
    "Empty & Error"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/upload-failed.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CloudAlertIcon, CloudCheckIcon, CloudUploadIcon, FileWarningIcon, RotateCwIcon, WifiOffIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type UploadIssue =\n  | { kind: \"too-large\"; maxBytes: number }\n  | { kind: \"type\"; allowed: string[]; hint?: string }\n  | { kind: \"connection\" };\n\nexport interface UploadFailedFile {\n  id: string;\n  name: string;\n  size: number;\n  progress: number;\n  status: \"uploading\" | \"done\" | \"failed\";\n  issue?: UploadIssue;\n}\n\nexport interface UploadFailedProps {\n  files?: UploadFailedFile[];\n  destination?: string;\n  onRetry?: (id: string) => void;\n  onRetryAll?: () => void;\n  onRemove?: (id: string) => void;\n  onRemoveFailed?: () => void;\n  onReplace?: (id: string, file: File) => void;\n  className?: string;\n}\n\nconst MB = 1024 ** 2;\n\nconst defaultFiles: UploadFailedFile[] = [\n  { id: \"brief\", name: \"survey-brief.pdf\", size: 2.4 * MB, progress: 1, status: \"done\" },\n  { id: \"walkthrough\", name: \"north-ridge-walkthrough.mov\", size: 18 * MB, progress: 0, status: \"failed\", issue: { kind: \"too-large\", maxBytes: 10 * MB } },\n  { id: \"photo\", name: \"trailhead-014.heic\", size: 3.1 * MB, progress: 0, status: \"failed\", issue: { kind: \"type\", allowed: [\"JPG\", \"PNG\", \"PDF\", \"MOV\", \"CSV\"], hint: \"Export HEIC photos as JPG first.\" } },\n  { id: \"responses\", name: \"visitor-responses.csv\", size: 6.8 * MB, progress: 0.62, status: \"failed\", issue: { kind: \"connection\" } },\n  { id: \"cover\", name: \"cover-north-ridge.jpg\", size: 1.2 * MB, progress: 1, status: \"done\" },\n];\n\nconst tones: { match: RegExp; className: string }[] = [\n  { match: /^pdf$/, className: \"text-red-600 dark:text-red-400\" },\n  { match: /^(jpe?g|png|gif|webp|heic|avif)$/, className: \"text-sky-700 dark:text-sky-400\" },\n  { match: /^(mov|mp4|webm)$/, className: \"text-violet-600 dark:text-violet-400\" },\n  { match: /^(csv|xlsx?)$/, className: \"text-emerald-700 dark:text-emerald-400\" },\n];\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 36 } as const;\nconst fill = { type: \"spring\", stiffness: 140, damping: 24 } as const;\n\n// Stays false until mounted so the server render and the first client render match.\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction extensionOf(name: string) {\n  return name.split(\".\").pop()?.toLowerCase() ?? \"\";\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < MB) return `${Math.max(1, Math.round(bytes / 1024))} KB`;\n  const mb = bytes / MB;\n  return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;\n}\n\nfunction listOf(items: string[]) {\n  if (items.length < 2) return items.join(\"\");\n  return `${items.slice(0, -1).join(\", \")} or ${items[items.length - 1]}`;\n}\n\nexport function UploadFailed({\n  files = defaultFiles,\n  destination = \"Q3 site survey\",\n  onRetry,\n  onRetryAll,\n  onRemove,\n  onRemoveFailed,\n  onReplace,\n  className,\n}: UploadFailedProps) {\n  const reduce = useSafeReducedMotion();\n  const failed = files.filter((f) => f.status === \"failed\");\n  const uploading = files.filter((f) => f.status === \"uploading\").length;\n  const done = files.filter((f) => f.status === \"done\").length;\n  const retryable = failed.filter((f) => f.issue?.kind === \"connection\").length;\n  const state = failed.length > 0 ? \"failed\" : uploading > 0 ? \"uploading\" : \"done\";\n\n  const title =\n    state === \"failed\"\n      ? `${failed.length} of ${files.length} didn't upload`\n      : state === \"uploading\"\n        ? `Uploading ${uploading} of ${files.length}`\n        : files.length === 0\n          ? \"Nothing to upload\"\n          : `${done} of ${files.length} uploaded`;\n\n  const Icon = state === \"failed\" ? CloudAlertIcon : state === \"uploading\" ? CloudUploadIcon : CloudCheckIcon;\n\n  return (\n    <section\n      aria-label={`Uploads to ${destination}`}\n      className={cn(\"w-full max-w-136 overflow-hidden rounded-2xl border bg-card shadow-xs/5\", className)}\n    >\n      <header className=\"flex flex-wrap items-center gap-x-4 gap-y-3 p-4\">\n        <div className=\"flex min-w-0 flex-1 basis-60 items-center gap-3\">\n          <span\n            className={cn(\n              \"flex size-10 shrink-0 items-center justify-center rounded-xl border bg-card shadow-xs/5 transition-colors duration-300 [&_svg]:size-5\",\n              state === \"failed\" ? \"text-destructive-foreground\" : state === \"done\" ? \"text-success-foreground\" : \"text-muted-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.8 }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n                key={state}\n                transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n              >\n                <Icon />\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <div className=\"grid min-w-0 gap-0.5\">\n            <h3 aria-live=\"polite\" className=\"truncate font-semibold text-base tabular-nums\">\n              {title}\n            </h3>\n            <p className=\"truncate text-muted-foreground text-sm\">\n              {state === \"failed\" ? `${done} saved to ` : state === \"uploading\" ? \"Saving to \" : \"All in \"}\n              <span className=\"text-foreground\">{destination}</span>\n            </p>\n          </div>\n        </div>\n        <AnimatePresence initial={false}>\n          {failed.length > 0 && (\n            <motion.div\n              animate={{ opacity: 1 }}\n              className=\"flex shrink-0 gap-2\"\n              exit={{ opacity: 0, transition: { duration: 0.15 } }}\n              initial={{ opacity: 0 }}\n            >\n              <Button disabled={retryable === 0} onClick={onRetryAll} size=\"sm\" variant=\"outline\">\n                <RotateCwIcon />\n                Retry all\n              </Button>\n              <Button onClick={onRemoveFailed} size=\"sm\" variant=\"ghost\">\n                Remove failed\n              </Button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </header>\n\n      <div aria-hidden className=\"flex gap-1 px-4 pb-4\">\n        {files.map((f) => (\n          <span className=\"relative h-1 flex-1 overflow-hidden rounded-full bg-muted\" key={f.id}>\n            <motion.span\n              animate={{ scaleX: f.status === \"failed\" ? 1 : f.progress }}\n              className={cn(\n                \"absolute inset-0 origin-left rounded-full transition-colors duration-300\",\n                f.status === \"failed\" ? \"bg-destructive\" : f.status === \"done\" ? \"bg-success\" : \"bg-primary\",\n              )}\n              initial={false}\n              transition={reduce ? { duration: 0 } : fill}\n            />\n          </span>\n        ))}\n      </div>\n\n      <ul className=\"border-t\">\n        <AnimatePresence initial={false}>\n          {files.map((file) => (\n            <UploadRow\n              file={file}\n              key={file.id}\n              onRemove={onRemove ? () => onRemove(file.id) : undefined}\n              onReplace={onReplace ? (picked) => onReplace(file.id, picked) : undefined}\n              onRetry={onRetry ? () => onRetry(file.id) : undefined}\n              reduce={reduce}\n            />\n          ))}\n        </AnimatePresence>\n      </ul>\n    </section>\n  );\n}\n\nfunction UploadRow({\n  file,\n  reduce,\n  onRetry,\n  onRemove,\n  onReplace,\n}: {\n  file: UploadFailedFile;\n  reduce: boolean;\n  onRetry?: () => void;\n  onRemove?: () => void;\n  onReplace?: (file: File) => void;\n}) {\n  const ext = extensionOf(file.name);\n  const tone = tones.find((t) => t.match.test(ext))?.className ?? \"text-muted-foreground\";\n  const issue = file.status === \"failed\" ? file.issue : undefined;\n  const percent = Math.round(file.progress * 100);\n\n  return (\n    <motion.li\n      animate={{ opacity: 1, height: \"auto\" }}\n      className=\"overflow-hidden border-b last:border-b-0\"\n      exit={{ opacity: 0, height: 0, transition: { duration: 0.22, ease: [0.23, 1, 0.32, 1] } }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      transition={spring}\n    >\n      <div className=\"flex gap-3 px-4 py-3\">\n        <span\n          aria-hidden\n          className={cn(\n            \"flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background font-mono font-semibold text-[10px] uppercase tracking-wide\",\n            tone,\n          )}\n        >\n          {ext.slice(0, 4)}\n        </span>\n        <div className=\"flex min-w-0 flex-1 flex-col justify-center\">\n          <div className=\"flex items-baseline gap-2\">\n            <span className=\"min-w-0 flex-1 truncate font-medium text-sm\">{file.name}</span>\n            <span className=\"shrink-0 text-muted-foreground text-xs tabular-nums\">\n              {file.status === \"uploading\" ? `${percent}%` : formatBytes(file.size)}\n            </span>\n          </div>\n          <Collapse reduce={reduce} show={file.status === \"uploading\" || issue?.kind === \"connection\"}>\n            <div\n              aria-label={`${file.name} upload progress`}\n              aria-valuemax={100}\n              aria-valuemin={0}\n              aria-valuenow={percent}\n              className=\"h-1 overflow-hidden rounded-full bg-muted\"\n              role=\"progressbar\"\n            >\n              <motion.div\n                animate={{ scaleX: file.progress }}\n                className={cn(\"h-full origin-left rounded-full transition-colors duration-300\", issue ? \"bg-destructive\" : \"bg-primary\")}\n                initial={false}\n                transition={reduce ? { duration: 0 } : fill}\n              />\n            </div>\n          </Collapse>\n          <Collapse reduce={reduce} show={!!issue}>\n            {issue && <IssueLine fileName={file.name} issue={issue} onReplace={onReplace} onRetry={onRetry} percent={percent} size={file.size} />}\n          </Collapse>\n        </div>\n        <div className=\"flex w-8 shrink-0 items-start justify-end\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {file.status === \"done\" ? (\n              <DrawnCheck key=\"done\" reduce={reduce} />\n            ) : file.status === \"failed\" && onRemove ? (\n              <motion.span\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                key=\"remove\"\n                transition={{ duration: 0.15 }}\n              >\n                <Button aria-label={`Remove ${file.name}`} onClick={onRemove} size=\"icon-sm\" variant=\"ghost\">\n                  <XIcon />\n                </Button>\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nfunction Collapse({ show, reduce, children }: { show: boolean; reduce: boolean; children: ReactNode }) {\n  return (\n    <AnimatePresence initial={false}>\n      {show && (\n        <motion.div\n          animate={{ height: \"auto\", opacity: 1 }}\n          className=\"overflow-hidden\"\n          exit={{ height: 0, opacity: 0 }}\n          initial={{ height: 0, opacity: 0 }}\n          transition={reduce ? { duration: 0 } : { duration: 0.24, ease: [0.23, 1, 0.32, 1] }}\n        >\n          <div className=\"pt-1.5\">{children}</div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction IssueLine({\n  issue,\n  fileName,\n  percent,\n  size,\n  onRetry,\n  onReplace,\n}: {\n  issue: UploadIssue;\n  fileName: string;\n  percent: number;\n  size: number;\n  onRetry?: () => void;\n  onReplace?: (file: File) => void;\n}) {\n  const input = useRef<HTMLInputElement>(null);\n\n  const reason =\n    issue.kind === \"too-large\"\n      ? `Too large (${formatBytes(size)}, max ${formatBytes(issue.maxBytes)})`\n      : issue.kind === \"type\"\n        ? `Type not allowed (.${extensionOf(fileName)})`\n        : `Connection dropped at ${percent}%`;\n\n  const ReasonIcon = issue.kind === \"connection\" ? WifiOffIcon : FileWarningIcon;\n  const accept = issue.kind === \"type\" ? issue.allowed.map((t) => `.${t.toLowerCase()}`).join(\",\") : undefined;\n\n  return (\n    <div className=\"grid gap-2\">\n      <p className=\"flex items-center gap-1.5 text-destructive-foreground text-xs\">\n        <ReasonIcon aria-hidden className=\"size-3.5 shrink-0\" />\n        <span className=\"tabular-nums\">{reason}</span>\n      </p>\n      {issue.kind === \"type\" && (\n        <p className=\"text-muted-foreground text-xs\">\n          Use {listOf(issue.allowed)}.{issue.hint ? ` ${issue.hint}` : \"\"}\n        </p>\n      )}\n      {issue.kind === \"connection\" && <p className=\"text-muted-foreground text-xs\">The first {percent}% is saved. Retry picks up from there.</p>}\n      <div className=\"flex\">\n        {issue.kind === \"connection\" ? (\n          <Button onClick={onRetry} size=\"xs\" variant=\"outline\">\n            <RotateCwIcon />\n            Retry from {percent}%\n          </Button>\n        ) : (\n          <>\n            <input\n              accept={accept}\n              aria-label={`Replacement for ${fileName}`}\n              className=\"sr-only\"\n              onChange={(e) => {\n                const picked = e.target.files?.[0];\n                if (picked) onReplace?.(picked);\n                e.target.value = \"\";\n              }}\n              ref={input}\n              tabIndex={-1}\n              type=\"file\"\n            />\n            <Button onClick={() => input.current?.click()} size=\"xs\" variant=\"outline\">\n              {issue.kind === \"too-large\" ? \"Choose a smaller file\" : \"Choose another file\"}\n            </Button>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.span\n      animate={{ opacity: 1, scale: 1 }}\n      aria-label=\"Uploaded\"\n      className=\"mt-1 flex size-6 items-center justify-center rounded-full bg-success/12 text-success-foreground\"\n      exit={{ opacity: 0, scale: 0.8 }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n      role=\"img\"\n      transition={spring}\n    >\n      <svg aria-hidden className=\"size-3.5\" fill=\"none\" viewBox=\"0 0 16 16\">\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d=\"M3.5 8.5l3 3 6-7\"\n          initial={reduce ? false : { pathLength: 0 }}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={2}\n          transition={{ duration: 0.32, delay: 0.08, ease: [0.65, 0, 0.35, 1] }}\n        />\n      </svg>\n    </motion.span>\n  );\n}\n"
    }
  ]
}