{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-thumbnail",
  "type": "registry:ui",
  "title": "File Thumbnail",
  "description": "File card by type that keeps the extension visible and shows upload progress.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/file-thumbnail.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ file kinds */\n\nexport type FileKind = \"pdf\" | \"image\" | \"sheet\" | \"doc\" | \"slides\" | \"code\" | \"archive\" | \"audio\" | \"video\" | \"other\";\n\nconst EXT: Record<string, FileKind> = {\n  pdf: \"pdf\",\n  png: \"image\", jpg: \"image\", jpeg: \"image\", gif: \"image\", webp: \"image\", avif: \"image\", svg: \"image\", heic: \"image\",\n  xls: \"sheet\", xlsx: \"sheet\", csv: \"sheet\", tsv: \"sheet\", numbers: \"sheet\", ods: \"sheet\",\n  doc: \"doc\", docx: \"doc\", txt: \"doc\", md: \"doc\", rtf: \"doc\", pages: \"doc\", odt: \"doc\",\n  ppt: \"slides\", pptx: \"slides\", key: \"slides\", odp: \"slides\",\n  js: \"code\", jsx: \"code\", ts: \"code\", tsx: \"code\", py: \"code\", rb: \"code\", go: \"code\", rs: \"code\", java: \"code\", json: \"code\", yml: \"code\", yaml: \"code\", html: \"code\", css: \"code\", sql: \"code\", sh: \"code\", swift: \"code\", kt: \"code\",\n  zip: \"archive\", gz: \"archive\", tar: \"archive\", rar: \"archive\", \"7z\": \"archive\", dmg: \"archive\",\n  mp3: \"audio\", wav: \"audio\", m4a: \"audio\", ogg: \"audio\", flac: \"audio\",\n  mp4: \"video\", mov: \"video\", webm: \"video\", mkv: \"video\", avi: \"video\",\n};\n\n/** Splits \"report.final.pdf\" into [\"report.final\", \"pdf\"]. Dotfiles and names without a dot have no extension. */\nexport function splitName(name: string): [string, string] {\n  const i = name.lastIndexOf(\".\");\n  return i > 0 && i < name.length - 1 ? [name.slice(0, i), name.slice(i + 1)] : [name, \"\"];\n}\n\nexport function fileKind(name: string, mime?: string): FileKind {\n  const ext = splitName(name)[1].toLowerCase();\n  if (EXT[ext]) return EXT[ext];\n  if (mime?.startsWith(\"image/\")) return \"image\";\n  if (mime?.startsWith(\"audio/\")) return \"audio\";\n  if (mime?.startsWith(\"video/\")) return \"video\";\n  if (mime === \"application/pdf\") return \"pdf\";\n  return \"other\";\n}\n\n/** 2_400_000 → \"2.4 MB\". Decimal units, the way the file browser on the same machine counts. */\nexport function formatBytes(bytes: number) {\n  if (bytes < 1000) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let v = bytes / 1000;\n  let u = 0;\n  while (v >= 1000 && u < units.length - 1) {\n    v /= 1000;\n    u++;\n  }\n  return `${v >= 100 || u === 0 ? Math.round(v) : v.toFixed(1)} ${units[u]}`;\n}\n\n/* ------------------------------------------------------------------ component */\n\nexport type FileStatus = \"idle\" | \"uploading\" | \"error\";\n\nexport type FileThumbnailProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  name: string;\n  /** Bytes. */\n  size?: number;\n  /** MIME type, used when the extension doesn't say. */\n  type?: string;\n  /** Shows the picture itself for images (a blob or remote URL). */\n  previewUrl?: string;\n  /** \"row\" for attachments and lists, \"tile\" for grids. */\n  layout?: \"row\" | \"tile\";\n  status?: FileStatus;\n  /** 0–1 while uploading. Leave undefined for an indeterminate spinner. */\n  progress?: number;\n  /** Shown instead of the size when status is \"error\". */\n  error?: string;\n  /** Opening the file: the whole card becomes the target. */\n  href?: string;\n  onOpen?: () => void;\n  /** Adds the remove button (it reads \"Cancel upload\" while uploading). */\n  onRemove?: () => void;\n  onRetry?: () => void;\n  /** Play a short entrance when the card mounts, e.g. a file just added. */\n  appear?: boolean;\n};\n\nexport function FileThumbnail({\n  name,\n  size,\n  type,\n  previewUrl,\n  layout = \"row\",\n  status = \"idle\",\n  progress,\n  error,\n  href,\n  onOpen,\n  onRemove,\n  onRetry,\n  appear = false,\n  className,\n  ...rest\n}: FileThumbnailProps) {\n  const reduce = !!useReducedMotion();\n  const id = useId();\n  const kind = fileKind(name, type);\n  const [, ext] = splitName(name);\n  const uploading = status === \"uploading\";\n  const failed = status === \"error\";\n\n  // A just-finished upload gets a moment: the ring completes, a check draws, then the card settles.\n  const [justDone, setJustDone] = useState(false);\n  const [prevStatus, setPrevStatus] = useState(status);\n  if (prevStatus !== status) {\n    setPrevStatus(status);\n    if (prevStatus === \"uploading\" && status === \"idle\") setJustDone(true);\n  }\n  useEffect(() => {\n    if (!justDone) return;\n    const t = window.setTimeout(() => setJustDone(false), 1100);\n    return () => window.clearTimeout(t);\n  }, [justDone]);\n\n  const pct = progress === undefined ? undefined : Math.min(1, Math.max(0, progress));\n  const meta = failed\n    ? (error ?? \"Upload failed\")\n    : uploading\n      ? null\n      : [ext ? ext.toUpperCase() : null, size !== undefined ? formatBytes(size) : null].filter(Boolean).join(\" · \");\n\n  const open = href || onOpen;\n  const Target = href ? \"a\" : \"button\";\n  const targetProps = href ? { href, target: \"_blank\", rel: \"noopener noreferrer\" } : { type: \"button\" as const, onClick: onOpen };\n\n  const enter = appear && !reduce ? { opacity: 0, scale: 0.96, y: 4 } : false;\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-labelledby={`${id}-name`}\n      aria-describedby={`${id}-meta`}\n      data-layout={layout}\n      data-status={justDone ? \"done\" : status}\n      data-kind={kind}\n      initial={enter}\n      animate={{ opacity: 1, scale: 1, y: 0 }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, transition: { duration: 0.14, ease: ease.in } }}\n      transition={reduce ? { duration: 0.12 } : spring.soft}\n      className={cn(\n        \"group/file relative min-w-0 rounded-xl border bg-raised shadow-[var(--shadow)] transition-[border-color,background-color] duration-150\",\n        failed ? \"border-danger/35\" : \"border-line\",\n        open && \"hover:border-line-2 hover:bg-hover has-[[data-target]:focus-visible]:border-fg-4\",\n        layout === \"row\" ? \"flex h-14 items-center gap-3 p-2 pr-1.5\" : \"flex w-full flex-col gap-2 p-1.5 pb-2.5\",\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.div>)}\n    >\n      {/* Preview */}\n      <div\n        className={cn(\n          \"relative grid shrink-0 place-items-center overflow-hidden bg-hover\",\n          layout === \"row\" ? \"size-10 rounded-lg\" : \"aspect-[4/3] w-full rounded-lg\",\n        )}\n      >\n        <Preview kind={kind} ext={ext} previewUrl={previewUrl} large={layout === \"tile\"} />\n        <AnimatePresence initial={false}>\n          {(uploading || justDone || failed) && (\n            <motion.div\n              key=\"veil\"\n              className={cn(\"absolute inset-0 grid place-items-center\", failed ? \"bg-hover\" : \"bg-raised/70\")}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.05 } }}\n              transition={{ duration: 0.15 }}\n            >\n              <ProgressRing value={justDone ? 1 : pct} state={failed ? \"error\" : justDone ? \"done\" : \"active\"} size={layout === \"tile\" ? 36 : 28} reduce={reduce} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Name and meta */}\n      <div className={cn(\"flex min-w-0 flex-col\", layout === \"row\" ? \"flex-1 gap-0.5\" : \"gap-0.5 px-1\")}>\n        <MiddleName id={`${id}-name`} name={name} />\n        <p id={`${id}-meta`} className={cn(\"flex min-w-0 items-center gap-1 text-[12px] leading-4\", failed ? \"text-danger\" : \"text-fg-3\")}>\n          {uploading ? (\n            <>\n              <span>Uploading</span>\n              {pct !== undefined && (\n                <>\n                  <span aria-hidden>·</span>\n                  <NumberFlow value={pct} format={{ style: \"percent\", maximumFractionDigits: 0 }} locales=\"en-US\" animated={!reduce} className=\"tabular\" />\n                </>\n              )}\n            </>\n          ) : (\n            <span className=\"truncate\">{meta}</span>\n          )}\n        </p>\n      </div>\n\n      {/* The whole card opens the file; buttons sit above this target. */}\n      {open && (\n        <Target\n          data-target\n          aria-labelledby={`${id}-name`}\n          className=\"absolute inset-0 rounded-[inherit] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n          {...targetProps}\n        />\n      )}\n\n      {(onRemove || (failed && onRetry)) && (\n        <div\n          className={cn(\n            \"z-10 flex shrink-0 items-center\",\n            layout === \"row\" && \"relative\",\n            layout === \"tile\" &&\n              \"absolute right-2.5 top-2.5 gap-1 rounded-lg transition-opacity duration-150 pointer-fine:opacity-0 pointer-fine:group-hover/file:opacity-100 pointer-fine:group-focus-within/file:opacity-100\",\n            layout === \"tile\" && (failed || uploading) && \"pointer-fine:opacity-100\",\n          )}\n        >\n          {failed && onRetry && (\n            <CardButton label={`Retry uploading ${name}`} onClick={onRetry} tile={layout === \"tile\"}>\n              <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n            </CardButton>\n          )}\n          {onRemove && (\n            <CardButton label={uploading ? `Cancel upload of ${name}` : `Remove ${name}`} onClick={onRemove} tile={layout === \"tile\"}>\n              <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" />\n            </CardButton>\n          )}\n        </div>\n      )}\n\n      {uploading && pct !== undefined && (\n        <span role=\"progressbar\" aria-label={`Uploading ${name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct * 100)} className=\"sr-only\" />\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {justDone ? `${name} uploaded` : failed ? `${name}: ${error ?? \"upload failed\"}` : \"\"}\n      </span>\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------ parts */\n\n/**\n * Middle truncation that keeps the extension: the start of the name shrinks with an\n * ellipsis, the last few characters and the extension never do. Pure CSS, so it\n * follows the card's width. The full name stays in the title and the accessible name.\n */\nfunction MiddleName({ name, id }: { name: string; id: string }) {\n  const [base, ext] = splitName(name);\n  const keep = Math.min(base.length, 6);\n  const head = base.slice(0, base.length - keep);\n  const tail = base.slice(base.length - keep) + (ext ? `.${ext}` : \"\");\n  return (\n    <p id={id} title={name} className=\"flex min-w-0 text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg\">\n      <span aria-hidden className=\"truncate\">{head}</span>\n      <span aria-hidden className=\"shrink-0 whitespace-pre\">{tail}</span>\n      <span className=\"sr-only\">{name}</span>\n    </p>\n  );\n}\n\nfunction Preview({ kind, ext, previewUrl, large }: { kind: FileKind; ext: string; previewUrl?: string; large: boolean }) {\n  const [loaded, setLoaded] = useState<string | null>(null);\n  const [broken, setBroken] = useState<string | null>(null);\n  if (previewUrl && broken !== previewUrl) {\n    return (\n      <>\n        <Glyph kind={kind} ext={ext} large={large} />\n        {/* eslint-disable-next-line @next/next/no-img-element -- previews are often local blob URLs */}\n        <img\n          src={previewUrl}\n          alt=\"\"\n          ref={(i) => {\n            if (i?.complete && i.naturalWidth && loaded !== previewUrl) setLoaded(previewUrl);\n          }}\n          onLoad={() => setLoaded(previewUrl)}\n          onError={() => setBroken(previewUrl)}\n          className={cn(\n            \"absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out-quart motion-reduce:transition-none\",\n            loaded === previewUrl ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </>\n    );\n  }\n  return <Glyph kind={kind} ext={ext} large={large} />;\n}\n\n// A page with a folded corner, a mark for the kind, and the extension on a tab.\nfunction Glyph({ kind, ext, large }: { kind: FileKind; ext: string; large: boolean }) {\n  const mark: Record<FileKind, React.ReactNode> = {\n    pdf: <path d=\"M7 11.5h10M7 14.5h10M7 17.5h6\" />,\n    doc: <path d=\"M7 11.5h10M7 14.5h10M7 17.5h7\" />,\n    sheet: <path d=\"M7 11h10v8H7zM7 15h10M11.5 11v8\" />,\n    slides: <path d=\"M7 11.5h10v6H7zM12 17.5v2\" />,\n    code: <path d=\"m10 12-2.5 2.75L10 17.5M14 12l2.5 2.75L14 17.5\" />,\n    image: <path d=\"m7 18 3.25-3.5 2.25 2.25 1.5-1.5L17 18M13.5 11.75a.25.25 0 1 1 0 .01\" />,\n    archive: <path d=\"M11 4.5v1.5M13 6v1.5M11 7.5V9M13 9v1.5M11 10.5V12M11.25 13.5h1.5v2.5h-1.5z\" />,\n    audio: <path d=\"M8 14.5v1M10.25 12.5v5M12.5 11v8M14.75 13v4M17 14.25v1.5\" />,\n    video: <path d=\"m10.5 12 5 2.75-5 2.75z\" />,\n    other: <path d=\"M7 12.5h10M7 15.5h6\" />,\n  };\n  const label = ext ? ext.slice(0, 4).toUpperCase() : \"\";\n  return (\n    <span aria-hidden className={cn(\"relative grid place-items-center\", large ? \"h-[52px] w-[42px]\" : \"h-[30px] w-6\")}>\n      <svg viewBox=\"0 0 24 30\" className=\"absolute inset-0 size-full text-fg-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth={large ? 1 : 1.3} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M4.5 1.5h10l5.5 5.5v19.5a2 2 0 0 1-2 2H4.5a2 2 0 0 1-2-2v-23a2 2 0 0 1 2-2z\" className=\"fill-raised\" />\n        <path d=\"M14.5 1.5V5a2 2 0 0 0 2 2h3.5\" />\n        <g className=\"text-fg-2\" stroke=\"currentColor\">\n          {mark[kind]}\n        </g>\n      </svg>\n      {label && (\n        <span\n          className={cn(\n            \"absolute left-[-3px] rounded-[3px] bg-fg font-mono font-medium leading-none tracking-[0.02em] text-frame\",\n            large ? \"bottom-[7px] px-1 py-[2.5px] text-[8.5px]\" : \"bottom-[3px] px-[3px] py-[1.5px] text-[6.5px]\",\n          )}\n        >\n          {label}\n        </span>\n      )}\n    </span>\n  );\n}\n\n/** Determinate ring that springs to each new value, a spinning arc when the total is unknown, a drawn check or cross at the end. */\nfunction ProgressRing({ value, state, size, reduce }: { value?: number; state: \"active\" | \"done\" | \"error\"; size: number; reduce: boolean }) {\n  const r = 10;\n  const c = 2 * Math.PI * r;\n  const sprung = useSpring(value ?? 0, reduce ? { duration: 0 } : { stiffness: 140, damping: 24 });\n  useEffect(() => {\n    sprung.set(value ?? 0);\n  }, [value, sprung]);\n  const offset = useTransform(sprung, (v) => c * (1 - v));\n  const draw = reduce ? { duration: 0 } : { duration: 0.3, ease: ease.out, delay: 0.1 };\n  return (\n    <span className={cn(\"grid place-items-center rounded-full\", state === \"error\" ? \"text-danger\" : \"text-fg\")} style={{ width: size, height: size }}>\n      <svg viewBox=\"0 0 24 24\" className=\"size-full -rotate-90\" fill=\"none\">\n        <circle cx=\"12\" cy=\"12\" r={r} stroke=\"currentColor\" strokeOpacity={0.18} strokeWidth={2} />\n        {state !== \"error\" &&\n          (value === undefined && state === \"active\" ? (\n            <circle cx=\"12\" cy=\"12\" r={r} stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeDasharray={`${c * 0.28} ${c}`} className=\"origin-center animate-spin [animation-duration:0.9s]\" />\n          ) : (\n            <motion.circle cx=\"12\" cy=\"12\" r={r} stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeDasharray={c} style={{ strokeDashoffset: offset }} />\n          ))}\n      </svg>\n      <svg viewBox=\"0 0 16 16\" className=\"absolute size-[45%]\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        {state === \"done\" && <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={draw} />}\n        {state === \"error\" && (\n          <>\n            <motion.path d=\"m5 5 6 6\" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={draw} />\n            <motion.path d=\"m11 5-6 6\" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ ...draw, delay: reduce ? 0 : 0.2 }} />\n          </>\n        )}\n      </svg>\n    </span>\n  );\n}\n\nfunction CardButton({ label, onClick, tile, children }: { label: string; onClick: () => void; tile: boolean; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      className={cn(\n        \"relative grid place-items-center rounded-md outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out-quart active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n        tile ? \"size-6 border border-line-2 bg-raised/90 text-fg-2 shadow-[var(--shadow)] backdrop-blur-sm hover:text-fg\" : \"size-7 text-fg-3 hover:bg-fg/[0.06] hover:text-fg\",\n      )}\n    >\n      <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        {children}\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/file-thumbnail"
}
