Skip to content
ilinxa/pro-ui

Media Library

alphav0.2.1

Drive-style media library — folders and files, lazy loading, drag-drop upload, drag-to-move, context menus, and multi-type preview.

Category: MediaUpdated: 2026-08-18Created: 2026-06-09Author: ilinxa

Context

A composition-first CMS/asset surface. It owns the Drive shell — storage-quota bar, type-filter chips, folder-card row, thumbnail grid, upload pipeline, and a preview dispatcher — and delegates every actual file render to the shipped viewers (pdf-viewer, code-block, markdown-editor, video-player) plus a folder-navigation file-tree. Ships shadcn-style: one batteries-included <MediaLibrary> plus a headless <MediaLibraryRoot> + à-la-carte parts, so consumers drop what they don't need and tree-shake the unused viewers away.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/media-library

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/media-library-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

11.9 GB of 46.6 GB used26%

Media library

Folders

Brand & logos

2 items · Mar 24

Documents

2 items · Jun 6

Photos

3 items · Jun 9

Video

1 item · Jun 1

Files

PNG512 × 512
PNG

favicon.png

JPG2400 × 1350
JPG

hero-home.jpg

PNG1200 × 630
PNG

og-card.png

Drag-drop files to upload, drag a card onto a folder to move, right-click for actions, double-click a file to preview.

Demo source

demo.tsxtsx
"use client"; import * as React from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { MediaLibrary } from "./media-library";import { MediaLibraryRoot } from "./parts/media-library-root";import { MediaLibraryBreadcrumbs } from "./parts/breadcrumbs";import { MediaLibraryFolderRow } from "./parts/folder-row";import { MediaLibraryFileGrid } from "./parts/file-grid";import { MediaLibraryLightbox } from "./parts/preview-lightbox";import { FilePreview } from "./parts/file-preview";import type { MediaNode, MediaUploadProgressFn } from "./types";import {  MEDIA_LIBRARY_CHILDREN,  MEDIA_LIBRARY_NODES,  MEDIA_LIBRARY_STORAGE,} from "./dummy-data"; /** A fully-resolved tree (children inlined) so demo mutations reflect immediately. */const FULL_TREE: MediaNode[] = MEDIA_LIBRARY_NODES.map((n) =>  n.type === "folder" ? { ...n, children: MEDIA_LIBRARY_CHILDREN[n.id] ?? [] } : n,); // ---- tiny immutable tree helpers ----function removeIds(nodes: MediaNode[], ids: Set<string>): MediaNode[] {  return nodes    .filter((n) => !ids.has(n.id))    .map((n) => (n.children ? { ...n, children: removeIds(n.children, ids) } : n));}function renameIn(nodes: MediaNode[], id: string, name: string): MediaNode[] {  return nodes.map((n) =>    n.id === id      ? { ...n, name }      : n.children        ? { ...n, children: renameIn(n.children, id, name) }        : n,  );}function collect(nodes: MediaNode[], ids: Set<string>, out: MediaNode[]) {  for (const n of nodes) {    if (ids.has(n.id)) out.push(n);    if (n.children) collect(n.children, ids, out);  }}function insertInto(nodes: MediaNode[], parentId: string | null, add: MediaNode[]): MediaNode[] {  if (parentId === null) return [...nodes, ...add];  return nodes.map((n) =>    n.id === parentId      ? { ...n, children: [...(n.children ?? []), ...add] }      : n.children        ? { ...n, children: insertInto(n.children, parentId, add) }        : n,  );}function fileToNode(file: File, parentId: string | null, i: number): MediaNode {  const url = URL.createObjectURL(file);  const isImg = file.type.startsWith("image/");  return {    id: `up-${Date.now()}-${i}`,    name: file.name,    type: "file",    ext: file.name.split(".").pop(),    mimeType: file.type,    size: file.size,    parentId,    url,    thumbnailUrl: isImg ? url : undefined,    modifiedAt: new Date().toISOString(),  };} function useLibraryState() {  const [nodes, setNodes] = React.useState<MediaNode[]>(FULL_TREE);   const onUpload = React.useCallback(    (files: File[], target: string | null, progress: MediaUploadProgressFn) =>      new Promise<MediaNode[]>((resolve) => {        let pct = 0;        const iv = setInterval(() => {          pct += 20;          progress(pct);          if (pct >= 100) {            clearInterval(iv);            const created = files.map((f, i) => fileToNode(f, target, i));            setNodes((prev) => insertInto(prev, target, created));            resolve(created);          }        }, 200);      }),    [],  );   const onMove = React.useCallback((ids: string[], target: string | null) => {    setNodes((prev) => {      const idSet = new Set(ids);      const moved: MediaNode[] = [];      collect(prev, idSet, moved);      const without = removeIds(prev, idSet);      return insertInto(        without,        target,        moved.map((m) => ({ ...m, parentId: target })),      );    });  }, []);   const onRename = React.useCallback(    (id: string, name: string) => setNodes((prev) => renameIn(prev, id, name)),    [],  );  const onDelete = React.useCallback(    (ids: string[]) => setNodes((prev) => removeIds(prev, new Set(ids))),    [],  );  const onCreateFolder = React.useCallback(    (parentId: string | null, name: string) =>      setNodes((prev) =>        insertInto(prev, parentId, [          { id: `fold-${Date.now()}`, name, type: "folder", parentId, children: [] },        ]),      ),    [],  );   return { nodes, onUpload, onMove, onRename, onDelete, onCreateFolder };} export default function MediaLibraryDemo() {  const full = useLibraryState();  const lighter = useLibraryState();   return (    <Tabs defaultValue="full" className="w-full">      <SwipeTabsList>        <TabsTrigger value="full">Full library</TabsTrigger>        <TabsTrigger value="lighter">Lighter (composed)</TabsTrigger>        <TabsTrigger value="readonly">Read-only gallery</TabsTrigger>        <TabsTrigger value="dispatcher">Just the preview</TabsTrigger>      </SwipeTabsList>       <TabsContent value="full" className="pt-4">        <MediaLibrary          nodes={full.nodes}          storage={MEDIA_LIBRARY_STORAGE}          pdfWorkerSrc={`https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.296/build/pdf.worker.min.mjs`}          onUpload={full.onUpload}          onMove={full.onMove}          onRename={full.onRename}          onDelete={full.onDelete}          onCreateFolder={full.onCreateFolder}          onDownload={() => {}}        />        <p className="mt-3 font-mono text-xs text-muted-foreground">          Drag-drop files to upload, drag a card onto a folder to move, right-click for          actions, double-click a file to preview.        </p>      </TabsContent>       <TabsContent value="lighter" className="pt-4">        {/* Hand-assembled: no quota / chips / sidebar / toolbar / details-pane.            Dropping the parts also drops their weight from the bundle. */}        <MediaLibraryRoot          nodes={lighter.nodes}          onMove={lighter.onMove}          onDelete={lighter.onDelete}          preview="lightbox"        >          <MediaLibraryBreadcrumbs />          <MediaLibraryFolderRow />          <MediaLibraryFileGrid />          <MediaLibraryLightbox />        </MediaLibraryRoot>      </TabsContent>       <TabsContent value="readonly" className="pt-4">        {/* No mutation handlers → every mutate affordance hides automatically. */}        <MediaLibrary nodes={FULL_TREE} storage={MEDIA_LIBRARY_STORAGE} showSidebar={false} />      </TabsContent>       <TabsContent value="dispatcher" className="pt-4">        <div className="grid gap-3 sm:grid-cols-2">          {[FULL_TREE[4], MEDIA_LIBRARY_CHILDREN["f-brand"][0]].map((node) => (            <div key={node.id} className="h-64 overflow-hidden rounded-xl border border-border">              <FilePreview node={node} variant="pane" />            </div>          ))}        </div>        <p className="mt-3 font-mono text-xs text-muted-foreground">          The standalone <code>&lt;FilePreview&gt;</code> dispatcher — no library shell.        </p>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for MediaLibrary when you need a Google-Drive-style asset manager — folders + files, lazy loading, drag-drop upload, drag-to-move, right-click menus, and rich multi-type preview. It composes the shipped viewers (pdf-viewer, code-block, markdown-editor, video-player) and a folder file-tree — you supply the data + the backend callbacks.

Full (batteries-included)

import { MediaLibrary } from "@/components/media-library"

export function Example() {
  return (
    <MediaLibrary
      nodes={tree}
      storage={{ used: 12.8e9, total: 50e9 }}
      onLoadChildren={(folderId) => fetchFolder(folderId)}
      onUpload={(files, folderId, progress) => uploadToCdn(files, folderId, progress)}
      onMove={(ids, target) => moveAssets(ids, target)}
      onRename={(id, name) => renameAsset(id, name)}
      onDelete={(ids) => deleteAssets(ids)}
      onCreateFolder={(parentId, name) => createFolder(parentId, name)}
    />
  )
}

Lighter (drop the parts you don't need)

Like shadcn's sidebar, every part is an export. Compose <MediaLibraryRoot> with only the pieces you want — omitting <MediaLibraryLightbox /> / <MediaLibraryDetailsPane /> also tree-shakes the heavy viewers (pdf.js / CodeMirror / marked) out of your bundle.

import {
  MediaLibraryRoot,
  MediaLibraryBreadcrumbs,
  MediaLibraryFolderRow,
  MediaLibraryFileGrid,
} from "@/components/media-library"

<MediaLibraryRoot nodes={tree} onMove={moveAssets} preview={false}>
  <MediaLibraryBreadcrumbs />
  <MediaLibraryFolderRow />
  <MediaLibraryFileGrid />
</MediaLibraryRoot>

Just the preview

import { FilePreview } from "@/components/media-library"

// Anywhere — no library shell. Routes by MIME/extension to the right viewer.
<FilePreview node={{ id: "1", name: "readme.md", type: "file",
  mimeType: "text/markdown", url: "/files/readme.md" }} />

Notes

  • Data: nodes are MediaNode (a superset of the shared FsNode) — give files a url + mimeType for preview, and width/height for the dimension badge.
  • Text preview(code / JSON / txt / Markdown) is fetched from the node's url; pass resolveTextContent for auth / signed URLs.
  • Capabilities are opt-in: omit onUpload / onMove / onDelete / onRename / onCreateFolder and the matching buttons, menu items, and dnd disappear (read-only gallery).
  • Uploads call onUploadonce per file; report that file's percent via the progress callback and resolve with the real MediaNode[].
  • Cut → paste-into-folder moves items; copy/duplicate is not implemented.

Features

  • Three tiers: full <MediaLibrary>, headless <MediaLibraryRoot> + parts, and standalone primitives (FilePreview, QuotaBar, FileCard)
  • Multi-type preview dispatcher (image / video / pdf / code / json / text / markdown) in both a side pane and a full-screen lightbox with prev/next
  • Lazy-loaded viewers (React.lazy) — dropping the preview parts drops pdf.js / CodeMirror / marked from the bundle
  • Drag-drop upload (optimistic items + per-file progress + retry) via a consumer onUpload callback
  • Drag-to-move files/folders with self/cycle drop validation; right-click context menus; cut → paste move
  • Storage-quota bar, type-filter chips with live counts, breadcrumb navigation, lazy onLoadChildren
  • Controlled/uncontrolled selection + current folder; imperative handle; full label i18n
  • v0.1.2 — F-cross-13 path-b sweep: item context-menu trigger drops `asChild` (`className="contents"` wrapper; right-click bubbles from the card in both backends). Zero public-API change.

Tags

medialibraryfilesdriveuploadpreviewdrag-dropfolderscms

Dependencies

shadcn primitives: button, context-menu, dialog, input, progress, skeleton
npm peer deps: @dnd-kit/core@^6.3.1, date-fns@^4.1.0, lucide-react@^1.11.0
internal: file-tree, pdf-viewer, code-block, markdown-editor, video-player