{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-thumbnail-frame",
  "title": "File Thumbnail Frame",
  "description": "Dependency-free thumbnail frame with a loading shimmer, fade-in, and muted fallback surface.",
  "registryDependencies": [
    "@retab/utils",
    "@retab/effect-key",
    "@retab/use-keyed-mount-effect"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-frame.tsx",
      "content": "\"use client\";\n\nimport type * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { getFileThumbnailExtension } from \"./file-thumbnail-extension\";\nimport { FileThumbnailFallback } from \"./file-thumbnail-fallback\";\nimport type {\n  FileThumbnailFrameProps,\n  FileThumbnailShape,\n  FileThumbnailSize,\n  FileThumbnailState,\n} from \"./file-thumbnail-frame-types\";\nimport { FileThumbnailImage } from \"./file-thumbnail-image\";\nimport { FileThumbnailShimmer } from \"./file-thumbnail-shimmer\";\n\nexport { FileThumbnailShimmer };\nexport type {\n  FileThumbnailFrameProps,\n  FileThumbnailShape,\n  FileThumbnailSize,\n  FileThumbnailState,\n};\n\nconst FILE_THUMBNAIL_SIZE_CLASS_NAME: Record<FileThumbnailSize, string> = {\n  xs: \"w-9\",\n  sm: \"w-10\",\n  md: \"w-12\",\n  lg: \"w-16\",\n  xl: \"w-20\",\n};\n\nconst FILE_THUMBNAIL_SHAPE_ASPECT_RATIO: Record<FileThumbnailShape, string> = {\n  document: \"3 / 4\",\n  square: \"1 / 1\",\n};\n\n/**\n * The dependency-free thumbnail frame: loading shimmer, image fade-in,\n * custom-rendered preview slot, and extension fallback.\n */\nexport function FileThumbnailFrame({\n  file,\n  className,\n  previewAspectRatio,\n  previewClassName,\n  previewContent,\n  previewImageUrl,\n  thumbnailShape,\n  thumbnailSize,\n  onPreviewError,\n  state,\n  style,\n  ...props\n}: FileThumbnailFrameProps) {\n  const extension = getFileThumbnailExtension(file);\n  const resolvedShape =\n    thumbnailShape ?? (previewAspectRatio === 1 ? \"square\" : \"document\");\n  const hasRenderableContent = hasRenderablePreviewContent(previewContent);\n  const resolvedState = resolveFileThumbnailState({\n    explicitState: state,\n    hasPreview: hasRenderableContent || Boolean(previewImageUrl),\n  });\n\n  return (\n    <div\n      {...props}\n      data-slot=\"file-thumbnail\"\n      data-thumbnail-shape={resolvedShape}\n      data-thumbnail-size={thumbnailSize}\n      className={cn(\n        \"bg-muted text-muted-foreground relative overflow-hidden rounded-md border\",\n        thumbnailSize\n          ? FILE_THUMBNAIL_SIZE_CLASS_NAME[thumbnailSize]\n          : undefined,\n        className,\n      )}\n      style={{\n        ...style,\n        aspectRatio:\n          style?.aspectRatio ??\n          (previewAspectRatio\n            ? formatFileThumbnailAspectRatio(previewAspectRatio)\n            : FILE_THUMBNAIL_SHAPE_ASPECT_RATIO[resolvedShape]),\n      }}\n    >\n      {resolvedState === \"loading\" ? (\n        <FileThumbnailShimmer />\n      ) : resolvedState === \"error\" ? (\n        <FileThumbnailFallback extension={extension} />\n      ) : hasRenderableContent ? (\n        <div className={cn(\"absolute inset-0\", previewClassName)}>\n          {previewContent}\n        </div>\n      ) : previewImageUrl ? (\n        <FileThumbnailImage\n          key={previewImageUrl}\n          url={previewImageUrl}\n          alt={file.name}\n          className={previewClassName}\n          fallback={<FileThumbnailFallback extension={extension} />}\n          onError={onPreviewError}\n        />\n      ) : (\n        <FileThumbnailFallback extension={extension} />\n      )}\n    </div>\n  );\n}\n\nfunction formatFileThumbnailAspectRatio(value: number) {\n  return value === 1 ? \"1 / 1\" : String(value);\n}\n\nexport function resolveFileThumbnailState({\n  explicitState,\n  hasPreview,\n}: {\n  explicitState?: FileThumbnailState;\n  hasPreview: boolean;\n}): FileThumbnailState {\n  if (explicitState) return explicitState;\n  return hasPreview ? \"loaded\" : \"error\";\n}\n\nexport function hasRenderablePreviewContent(value: React.ReactNode): boolean {\n  return value !== null && value !== undefined && value !== false;\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-frame.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-frame-types.ts",
      "content": "import type * as React from \"react\";\n\nexport interface ThumbnailFile {\n  name: string;\n  type: string;\n}\n\nexport type FileThumbnailState = \"loading\" | \"loaded\" | \"error\";\n\nexport type FileThumbnailShape = \"document\" | \"square\";\n\nexport type FileThumbnailSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n\nexport interface FileThumbnailFrameProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The file being previewed. A browser `File` works too. */\n  file: ThumbnailFile | File;\n  /** Aspect ratio of the preview frame (width / height). Defaults to 3 / 4. */\n  previewAspectRatio?: number;\n  /** Common thumbnail geometry. Defaults to document unless previewAspectRatio is provided. */\n  thumbnailShape?: FileThumbnailShape;\n  /** Common thumbnail width token. Use className for custom dimensions. */\n  thumbnailSize?: FileThumbnailSize;\n  previewClassName?: string;\n  /** Custom React preview (e.g. a rendered PDF page). Takes priority over the image. */\n  previewContent?: React.ReactNode;\n  /** Externally generated thumbnail image URL. */\n  previewImageUrl?: string | null;\n  /** Called when the browser image preview fails to load. */\n  onPreviewError?: () => void;\n  /** Explicit preview lifecycle. */\n  state?: FileThumbnailState;\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-frame-types.ts"
    },
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-extension.ts",
      "content": "import type { ThumbnailFile } from \"./file-thumbnail-frame-types\";\n\nexport function getFileThumbnailExtension(\n  file: ThumbnailFile | File,\n): string | null {\n  const fromName = extensionFromName(file.name);\n  if (fromName) return fromName.toLowerCase();\n  const subtype = mimeSubtypeToExtension(file.type);\n  return subtype ? subtype.toLowerCase() : null;\n}\n\nfunction extensionFromName(name: string | undefined): string | null {\n  if (!name) return null;\n  const clean = name.split(/[?#]/)[0];\n  const base = clean.split(/[\\\\/]/).pop() ?? clean;\n  if (!base.includes(\".\")) return null;\n  return base.split(\".\").pop() || null;\n}\n\nfunction mimeSubtypeToExtension(type: string | undefined): string | null {\n  if (!type) return null;\n  const normalized = type.toLowerCase().split(\";\")[0].trim();\n  if (normalized in MIME_EXTENSION) return MIME_EXTENSION[normalized];\n  const subtype = normalized.split(\"/\").pop();\n  return subtype || null;\n}\n\nconst MIME_EXTENSION: Record<string, string> = {\n  \"application/pdf\": \"pdf\",\n  \"application/vnd.ms-excel\": \"xls\",\n  \"application/vnd.ms-powerpoint\": \"ppt\",\n  \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n    \"pptx\",\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\": \"xlsx\",\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":\n    \"docx\",\n  \"image/jpeg\": \"jpg\",\n  \"image/png\": \"png\",\n  \"image/tiff\": \"tiff\",\n  \"text/csv\": \"csv\",\n  \"text/html\": \"html\",\n  \"text/markdown\": \"md\",\n  \"text/plain\": \"txt\",\n};\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-extension.ts"
    },
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-fallback.tsx",
      "content": "export function FileThumbnailFallback({\n  extension,\n}: {\n  extension: string | null;\n}) {\n  return (\n    <div\n      data-slot=\"file-thumbnail-fallback\"\n      className=\"absolute inset-0 flex flex-col items-center justify-center gap-1.5\"\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        className=\"size-1/3 opacity-40\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        aria-hidden\n      >\n        <path d=\"M6 2.5h8L19 7v13.5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-17a1 1 0 0 1 1-1Z\" />\n        <path d=\"M14 2.5V7h5\" />\n      </svg>\n      {extension ? (\n        <span className=\"max-w-[80%] truncate text-[0.625rem] font-medium tracking-wide uppercase opacity-70\">\n          {extension}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-fallback.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-shimmer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useKeyedMountEffect } from \"@/hooks/use-keyed-mount-effect\";\nimport { joinEffectKey } from \"@/lib/effect-key\";\n\nexport function FileThumbnailShimmer() {\n  const highlightRef = React.useRef<HTMLDivElement | null>(null);\n  const prefersReducedMotion = usePrefersReducedMotion();\n\n  useKeyedMountEffect(joinEffectKey([prefersReducedMotion]), () => {\n    const highlight = highlightRef.current;\n    if (!highlight || prefersReducedMotion || !highlight.animate) return;\n\n    const animation = highlight.animate(\n      [{ backgroundPosition: \"200% 0\" }, { backgroundPosition: \"-200% 0\" }],\n      {\n        duration: 1600,\n        iterations: Infinity,\n        easing: \"linear\",\n      },\n    );\n\n    return () => animation.cancel();\n  });\n\n  return (\n    <div\n      aria-hidden\n      data-slot=\"file-thumbnail-shimmer\"\n      className=\"bg-muted absolute inset-0 overflow-hidden\"\n    >\n      <div\n        ref={highlightRef}\n        data-slot=\"file-thumbnail-shimmer-highlight\"\n        className=\"absolute inset-0\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(120deg, transparent 35%, var(--skeleton-highlight, color-mix(in oklab, var(--background) 85%, transparent)) 50%, transparent 65%)\",\n          backgroundSize: \"200% 100%\",\n          backgroundRepeat: \"no-repeat\",\n          backgroundPosition: prefersReducedMotion ? \"50% 0\" : \"200% 0\",\n        }}\n      />\n    </div>\n  );\n}\n\nfunction usePrefersReducedMotion(): boolean {\n  return React.useSyncExternalStore(\n    subscribeToReducedMotion,\n    getReducedMotionSnapshot,\n    () => false,\n  );\n}\n\nfunction subscribeToReducedMotion(onChange: () => void) {\n  if (typeof window === \"undefined\" || !window.matchMedia) return () => {};\n\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  if (query.addEventListener) {\n    query.addEventListener(\"change\", onChange);\n    return () => query.removeEventListener(\"change\", onChange);\n  }\n\n  const legacyQuery = query as MediaQueryList & {\n    addListener?: (listener: () => void) => void;\n    removeListener?: (listener: () => void) => void;\n  };\n  legacyQuery.addListener?.(onChange);\n  return () => legacyQuery.removeListener?.(onChange);\n}\n\nfunction getReducedMotionSnapshot() {\n  if (typeof window === \"undefined\" || !window.matchMedia) return false;\n\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-shimmer.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/file-thumbnail-image.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { FileThumbnailShimmer } from \"./file-thumbnail-shimmer\";\n\nexport function FileThumbnailImage({\n  url,\n  alt,\n  className,\n  fallback,\n  onError,\n}: {\n  url: string;\n  alt: string;\n  className?: string;\n  fallback: React.ReactNode;\n  onError?: () => void;\n}) {\n  const [loaded, setLoaded] = React.useState(false);\n  const [failed, setFailed] = React.useState(false);\n  const didReportErrorRef = React.useRef(false);\n\n  const reportError = React.useCallback(() => {\n    if (didReportErrorRef.current) return;\n    didReportErrorRef.current = true;\n    onError?.();\n  }, [onError]);\n\n  // A cached image can complete before React attaches `onLoad`.\n  const imgRef = React.useCallback(\n    (img: HTMLImageElement | null) => {\n      if (!img) return;\n      if (img.complete) {\n        if (img.naturalWidth > 0) setLoaded(true);\n        else {\n          setFailed(true);\n          reportError();\n        }\n      }\n    },\n    [reportError],\n  );\n\n  if (failed) return <>{fallback}</>;\n\n  return (\n    <>\n      <img\n        ref={imgRef}\n        src={url}\n        alt={alt}\n        loading=\"lazy\"\n        decoding=\"async\"\n        onLoad={() => setLoaded(true)}\n        onError={() => {\n          setFailed(true);\n          reportError();\n        }}\n        className={cn(\n          \"absolute inset-0 size-full object-cover transition-opacity duration-300\",\n          loaded ? \"opacity-100\" : \"opacity-0\",\n          className,\n        )}\n      />\n      {loaded ? null : <FileThumbnailShimmer />}\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-thumbnail-image.tsx"
    }
  ],
  "type": "registry:ui"
}