Media Library
alphav0.2.1Drive-style media library — folders and files, lazy loading, drag-drop upload, drag-to-move, context menus, and multi-type preview.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/media-libraryAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/media-library-fixturesCLI 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
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
favicon.png
hero-home.jpg
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
"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><FilePreview></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 sharedFsNode) — give files aurl+mimeTypefor preview, andwidth/heightfor the dimension badge. - Text preview(code / JSON / txt / Markdown) is fetched from the node's
url; passresolveTextContentfor auth / signed URLs. - Capabilities are opt-in: omit
onUpload/onMove/onDelete/onRename/onCreateFolderand the matching buttons, menu items, and dnd disappear (read-only gallery). - Uploads call
onUploadonce per file; report that file's percent via theprogresscallback and resolve with the realMediaNode[]. - 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.