{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-list",
  "type": "registry:component",
  "title": "File List",
  "description": "Reactive list of stored files with thumbnails, download and delete.",
  "dependencies": [
    "files-sdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/files-sdk/file-list/file-list.tsx",
      "type": "registry:component",
      "target": "components/files-sdk/file-list.tsx",
      "content": "\"use client\";\n\nimport type { StoredFile } from \"files-sdk\";\nimport type { UseFilesResult } from \"files-sdk/react\";\nimport {\n  DownloadIcon,\n  FileIcon,\n  Loader2Icon,\n  RefreshCwIcon,\n  Trash2Icon,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FileListProps {\n  /** A `useFiles()` instance — lists, downloads and deletes through it. */\n  files: UseFilesResult;\n  /** Only show keys under this prefix (folder), e.g. `\"docs/\"`. */\n  prefix?: string;\n  /** Endpoint for inline image thumbnails (the gateway download proxy). Default `\"/api/files\"`. */\n  endpoint?: string;\n  /** Hide the delete action. */\n  readOnly?: boolean;\n  /** Called when a file row is clicked. Rows are inert without it. */\n  onSelect?: (file: StoredFile) => void;\n  /** Called after a successful delete. */\n  onChanged?: () => void;\n  className?: string;\n}\n\nconst formatBytes = (bytes: number): string => {\n  if (bytes === 0) {\n    return \"0 B\";\n  }\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const exponent = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1\n  );\n  return `${(bytes / 1024 ** exponent).toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n};\n\nconst Thumbnail = ({\n  canSign,\n  endpoint,\n  file,\n  filesRef,\n}: {\n  canSign: boolean | undefined;\n  endpoint: string;\n  file: StoredFile;\n  filesRef: { current: UseFilesResult };\n}) => {\n  const [src, setSrc] = useState<string>();\n  const [failed, setFailed] = useState(false);\n  const isImage = file.type.startsWith(\"image/\");\n\n  useEffect(() => {\n    if (!isImage || canSign === undefined) {\n      return;\n    }\n    // Prefer a signed/direct URL when the adapter can mint one — some dev\n    // servers (e.g. TanStack Start's nitro dev middleware) treat any\n    // `Sec-Fetch-Dest: image` request to a catch-all route as a static asset\n    // and 404 it before the gateway runs, so an <img> pointed at the download\n    // proxy never loads there. Fall back to the proxy, which works on every\n    // adapter — even ones that can't sign.\n    const proxy = `${endpoint}${endpoint.includes(\"?\") ? \"&\" : \"?\"}op=download&key=${encodeURIComponent(file.key)}`;\n    if (!canSign) {\n      setSrc(proxy);\n      return;\n    }\n    let cancelled = false;\n    const resolve = async () => {\n      let next = proxy;\n      try {\n        next = await filesRef.current.url(file.key);\n      } catch {\n        next = proxy;\n      }\n      if (!cancelled) {\n        setSrc(next);\n      }\n    };\n    void resolve();\n    return () => {\n      cancelled = true;\n    };\n  }, [canSign, endpoint, file.key, filesRef, isImage]);\n\n  // A plain <img> (not next/image) keeps the component portable to any React\n  // app. A load failure degrades to the generic icon instead of the browser's\n  // broken-image glyph.\n  if (isImage && src && !failed) {\n    return (\n      // eslint-disable-next-line nextjs/no-img-element\n      <img\n        alt={file.key}\n        className=\"size-10 shrink-0 rounded object-cover\"\n        onError={() => setFailed(true)}\n        src={src}\n      />\n    );\n  }\n  return (\n    <span className=\"bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded\">\n      <FileIcon className=\"size-4\" />\n    </span>\n  );\n};\n\n/**\n * Reactive list of stored files for a `useFiles()` instance — thumbnails, size\n * and type, with download and delete actions. Deletes go through the same\n * instance so ambient error state stays consistent.\n */\nexport const FileList = ({\n  files,\n  prefix,\n  endpoint = \"/api/files\",\n  readOnly = false,\n  onSelect,\n  onChanged,\n  className,\n}: FileListProps) => {\n  const [items, setItems] = useState<StoredFile[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [canSign, setCanSign] = useState<boolean>();\n\n  // Read `files` through a ref so the fetch effect depends only on `prefix`.\n  // The hook returns a fresh object whenever its ambient store changes (e.g. on\n  // a failed call), so depending on `files` directly would re-run the effect on\n  // every such change — an infinite loop the moment a `list` errors.\n  const filesRef = useRef(files);\n  filesRef.current = files;\n\n  const refresh = useCallback(async () => {\n    setIsLoading(true);\n    try {\n      const result = await filesRef.current.list(\n        prefix ? { prefix } : undefined\n      );\n      setItems(result.items);\n    } catch {\n      // Leave the current items in place; the hook mirrors the error to\n      // `files.error` for display. Crucially, don't re-fetch on failure.\n    } finally {\n      setIsLoading(false);\n    }\n  }, [prefix]);\n\n  useEffect(() => {\n    void refresh();\n  }, [refresh]);\n\n  // Resolved once per mount: whether thumbnails can use signed/direct URLs.\n  useEffect(() => {\n    let cancelled = false;\n    const resolve = async () => {\n      try {\n        const caps = await filesRef.current.capabilities();\n        if (!cancelled) {\n          setCanSign(caps.signedUrl.supported);\n        }\n      } catch {\n        // Treat an unreachable/denied capabilities op as \"can't sign\".\n        if (!cancelled) {\n          setCanSign(false);\n        }\n      }\n    };\n    void resolve();\n    return () => {\n      cancelled = true;\n    };\n  }, []);\n\n  const remove = useCallback(\n    async (key: string) => {\n      await filesRef.current.delete(key);\n      setItems((prev) => prev.filter((item) => item.key !== key));\n      onChanged?.();\n    },\n    [onChanged]\n  );\n\n  const download = useCallback(async (file: StoredFile) => {\n    const downloaded = await filesRef.current.download(file.key);\n    const blob = await downloaded.blob();\n    const url = URL.createObjectURL(blob);\n    const anchor = document.createElement(\"a\");\n    anchor.download = file.key.split(\"/\").pop() ?? file.key;\n    anchor.href = url;\n    anchor.click();\n    URL.revokeObjectURL(url);\n  }, []);\n\n  if (isLoading && !items.length) {\n    return (\n      <div\n        className={cn(\n          \"text-muted-foreground flex items-center justify-center gap-2 p-8 text-sm\",\n          className\n        )}\n      >\n        <Loader2Icon className=\"size-4 animate-spin\" /> Loading…\n      </div>\n    );\n  }\n\n  if (!items.length) {\n    return (\n      <div\n        className={cn(\n          \"text-muted-foreground flex flex-col items-center gap-1 p-8 text-center text-sm\",\n          className\n        )}\n      >\n        <FileIcon className=\"size-6\" />\n        Nothing here yet.\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <p className=\"text-muted-foreground text-xs\">{items.length} files</p>\n        <Button\n          onClick={() => void refresh()}\n          size=\"icon-sm\"\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          <RefreshCwIcon className={cn(isLoading && \"animate-spin\")} />\n        </Button>\n      </div>\n      <ul className=\"flex flex-col gap-2\">\n        {items.map((item) => (\n          <li\n            className=\"border-border flex items-center gap-3 rounded-lg border p-2\"\n            key={item.key}\n          >\n            <button\n              className=\"hover:bg-muted -m-1 flex min-w-0 flex-1 items-center gap-3 rounded-md p-1 text-left transition-colors disabled:cursor-default disabled:hover:bg-transparent\"\n              disabled={!onSelect}\n              onClick={() => onSelect?.(item)}\n              type=\"button\"\n            >\n              <Thumbnail\n                canSign={canSign}\n                endpoint={endpoint}\n                file={item}\n                filesRef={filesRef}\n              />\n              <span className=\"min-w-0 flex-1\">\n                <span className=\"block truncate text-sm font-medium\">\n                  {item.key}\n                </span>\n                <span className=\"text-muted-foreground block text-xs\">\n                  {formatBytes(item.size)} · {item.type || \"unknown\"}\n                </span>\n              </span>\n            </button>\n            <Button\n              onClick={() => void download(item)}\n              size=\"icon-sm\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <DownloadIcon />\n            </Button>\n            {!readOnly && (\n              <Button\n                onClick={() => void remove(item.key)}\n                size=\"icon-sm\"\n                type=\"button\"\n                variant=\"destructive\"\n              >\n                <Trash2Icon />\n              </Button>\n            )}\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n};\n"
    }
  ]
}
