v1.0

MediaLibrary

Preview

Basic

Loading…

Preview

Code

ts
import MediaLibrary from '@/components/media/MediaLibrary';

src/components/media/MediaLibrary.tsx

AI prompt

text
Build a searchable media library picker component (grid and list views, drag-and-drop upload) in React + TypeScript + Tailwind CSS.

## Look
- Root `flex flex-col gap-3`.
- Toolbar `flex flex-wrap items-center gap-2`:
  - Search: `relative min-w-48 flex-1`, a 14px Search icon at `left-2.5`, and a text input with `pl-8` and placeholder "Search media…".
  - View toggle: `flex items-center gap-0.5 rounded-lg border border-slate-200 dark:border-slate-700 p-0.5` holding two `rounded-md p-1.5` icon buttons (Grid2x2, List; 14px). The active one is `bg-indigo-600 text-white`; the other is `text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800`.
  - A primary "Upload" button with an Upload icon, only when `onUpload` is passed. Make it a `relative` <label> around a visually hidden file input; `relative` keeps the sr-only input from escaping and stretching the page.
- Drop zone around the items: `min-h-0 flex-1 rounded-xl border border-dashed p-3 transition-colors`, `border-slate-200 dark:border-slate-700`. While a file is dragged over it: `border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10`.
- Grid view: `grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3` of tile buttons, `relative rounded-lg border p-2 text-left`.
  - Unselected: `border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600`.
  - Selected: `border-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10`, plus a 16px indigo-600 circle at `right-1.5 top-1.5` holding a white 10px Check (strokeWidth 3).
  - Contents: a square thumbnail (`aspect-square w-full rounded-md object-cover bg-slate-100 dark:bg-slate-800`), then the name (`mt-1.5 truncate text-[11px] font-medium`), then the size (10px slate-400).
- List view: `divide-y divide-slate-100 dark:divide-slate-800` of row buttons, `flex w-full items-center gap-3 rounded-md px-2 py-1.5`.
  - Selected: `bg-indigo-50/60 dark:bg-indigo-500/10`. Otherwise: `hover:bg-slate-50 dark:hover:bg-slate-800/50`.
  - Contents: a 36px thumbnail, the name (text-xs font-medium, truncating), a meta line (10px slate-400, "image · 471 KB · 2026-08-28"), and a 14px indigo-600 Check when selected.
- Thumbnails use `thumbnailUrl`, else `url` for images. If there is no source, or the image fails to load, show a slate-100 / dark slate-800 box with a 20px slate-400 icon for the kind (image/other: File, video: Film, audio: Music, document: FileText). Never show a broken-image glyph: dead URLs are common here, and a grid of broken images reads as a broken component.
- Empty: centred `py-16` text-sm font-medium, "Nothing matches that search" or "No media yet". When upload is enabled, add an ⓘ tooltip: "Drop files here, or use Upload."

## Behaviour
- Search filters by name, case-insensitive. The view (grid by default) is internal state.
- Selection is CONTROLLED and always a `string[]`, even when `multiple` is false.
  - Multiple: a click toggles the item.
  - Single: a click gives `[id]`, or `[]` when that item is clicked again.
  - Without `onSelectedChange` the library is read-only.
- Upload: through the file input (`multiple` follows the prop; `accept` is passed through), or by dropping files on the zone (preventDefault on dragover). Both call `onUpload(FileList)`.
- Sizes: base 1024, units B / KB / MB / GB, one decimal under 10 (except bytes), otherwise rounded.

## API
- `MediaItem = { id: string; name: string; kind: 'image' | 'video' | 'audio' | 'document' | 'other'; size?: number; url?: string; thumbnailUrl?: string; uploadedAt?: string }` (`size` is in bytes)
- `items`, `selected?` = [], `onSelectedChange?(ids)`, `multiple?` = true, `onUpload?(files)` (omit it to hide upload entirely), `accept?`, `className?`

## Accessibility
- Tiles and rows: `aria-pressed`. View buttons: `aria-label` "grid view" / "list view", with `aria-pressed`.

## Demo
Eight items: hero-banner.png, onboarding.mp4, terms-v3.pdf, launch-jingle.mp3, avatar-ada.png, export.csv, screenshot-01.png, walkthrough.mov. Use realistic sizes, from 5 KB to 41 MB, and August 2026 upload dates. Pre-select the first. `onUpload` prepends the dropped files as new items, with `URL.createObjectURL` for their url.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useMemo, useState } from 'react';
import { Check, File, FileText, Film, Grid2x2, List, Music, Search, Upload } from 'lucide-react';
import { cn } from '@/lib/cn';
import EmptyState from '@/components/layout/EmptyState';

export type MediaItem = {
  id: string;
  name: string;
  /** Drives the placeholder icon and the preview treatment. */
  kind: 'image' | 'video' | 'audio' | 'document' | 'other';
  /** Bytes. Rendered human-readable. */
  size?: number;
  url?: string;
  thumbnailUrl?: string;
  uploadedAt?: string;
};

const ICON = {
  image: File,
  video: Film,
  audio: Music,
  document: FileText,
  other: File,
} as const;

function humanSize(bytes?: number) {
  if (bytes == null) return '';
  const units = ['B', 'KB', 'MB', 'GB'];
  let n = bytes;
  let u = 0;
  while (n >= 1024 && u < units.length - 1) {
    n /= 1024;
    u++;
  }
  return `${n < 10 && u > 0 ? n.toFixed(1) : Math.round(n)} ${units[u]}`;
}

/**
 * A picker for files that already exist, with an upload affordance beside them.
 *
 * Selection is CONTROLLED and always an array, even when `multiple` is false —
 * one shape for the caller to handle rather than a union that every call site
 * has to narrow.
 *
 * Thumbnails fall back to a kind icon rather than a broken-image glyph: a media
 * library is exactly where a dead URL is most likely, and a grid of broken
 * images reads as a broken component.
 */
export default function MediaLibrary({
  items,
  selected = [],
  onSelectedChange,
  multiple = true,
  onUpload,
  accept,
  className,
}: {
  items: MediaItem[];
  selected?: string[];
  onSelectedChange?: (next: string[]) => void;
  multiple?: boolean;
  /** Omit to hide the upload control entirely. */
  onUpload?: (files: FileList) => void;
  accept?: string;
  className?: string;
}) {
  const [query, setQuery] = useState('');
  const [view, setView] = useState<'grid' | 'list'>('grid');
  const [dragging, setDragging] = useState(false);

  const visible = useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items;
  }, [items, query]);

  const toggle = (id: string) => {
    if (!onSelectedChange) return;
    if (!multiple) return onSelectedChange(selected.includes(id) ? [] : [id]);
    onSelectedChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
  };

  const Thumb = ({ item, size }: { item: MediaItem; size: 'sm' | 'lg' }) => {
    const [failed, setFailed] = useState(false);
    const src = item.thumbnailUrl ?? (item.kind === 'image' ? item.url : undefined);
    const Icon = ICON[item.kind];
    const box = size === 'lg' ? 'aspect-square w-full' : 'h-9 w-9 shrink-0';
    if (src && !failed) {
      return (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={src}
          alt=""
          onError={() => setFailed(true)}
          className={cn(box, 'rounded-md object-cover bg-slate-100 dark:bg-slate-800')}
        />
      );
    }
    return (
      <div className={cn(box, 'grid place-items-center rounded-md bg-slate-100 dark:bg-slate-800')}>
        <Icon className="h-5 w-5 text-slate-400" />
      </div>
    );
  };

  return (
    <div className={cn('flex flex-col gap-3', className)}>
      <div className="flex flex-wrap items-center gap-2">
        <div className="relative min-w-48 flex-1">
          <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search media…"
            className="field-input pl-8"
          />
        </div>

        <div className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5 dark:border-slate-700">
          {([['grid', Grid2x2], ['list', List]] as const).map(([v, Icon]) => (
            <button
              key={v}
              onClick={() => setView(v)}
              aria-label={`${v} view`}
              aria-pressed={view === v}
              className={cn(
                'rounded-md p-1.5',
                view === v
                  ? 'bg-indigo-600 text-white'
                  : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800',
              )}
            >
              <Icon className="h-3.5 w-3.5" />
            </button>
          ))}
        </div>

        {onUpload && (
          // `relative` anchors the input: `sr-only` is `position: absolute`, and
          // with no positioned ancestor it escapes the scrolling `<main>` and
          // stretches the document itself — a second, page-length scrollbar.
          <label className="btn-primary relative cursor-pointer">
            <Upload className="h-3.5 w-3.5" />
            Upload
            <input
              type="file"
              multiple={multiple}
              accept={accept}
              className="sr-only"
              onChange={(e) => e.target.files?.length && onUpload(e.target.files)}
            />
          </label>
        )}
      </div>

      <div
        onDragOver={onUpload ? (e) => { e.preventDefault(); setDragging(true); } : undefined}
        onDragLeave={onUpload ? () => setDragging(false) : undefined}
        onDrop={
          onUpload
            ? (e) => {
                e.preventDefault();
                setDragging(false);
                if (e.dataTransfer.files.length) onUpload(e.dataTransfer.files);
              }
            : undefined
        }
        className={cn(
          'min-h-0 flex-1 rounded-xl border border-dashed p-3 transition-colors',
          dragging
            ? 'border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10'
            : 'border-slate-200 dark:border-slate-700',
        )}
      >
        {visible.length === 0 ? (
          <EmptyState
            title={query ? 'Nothing matches that search' : 'No media yet'}
            hint={onUpload ? 'Drop files here, or use Upload.' : undefined}
          />
        ) : view === 'grid' ? (
          <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
            {visible.map((item) => {
              const on = selected.includes(item.id);
              return (
                <button
                  key={item.id}
                  onClick={() => toggle(item.id)}
                  aria-pressed={on}
                  className={cn(
                    'group relative rounded-lg border p-2 text-left transition-colors',
                    on
                      ? 'border-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10'
                      : 'border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600',
                  )}
                >
                  <Thumb item={item} size="lg" />
                  <p className="mt-1.5 truncate text-[11px] font-medium text-slate-700 dark:text-slate-200">
                    {item.name}
                  </p>
                  <p className="text-[10px] text-slate-400">{humanSize(item.size)}</p>
                  {on && (
                    <span className="absolute right-1.5 top-1.5 grid h-4 w-4 place-items-center rounded-full bg-indigo-600">
                      <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
                    </span>
                  )}
                </button>
              );
            })}
          </div>
        ) : (
          <ul className="divide-y divide-slate-100 dark:divide-slate-800">
            {visible.map((item) => {
              const on = selected.includes(item.id);
              return (
                <li key={item.id}>
                  <button
                    onClick={() => toggle(item.id)}
                    aria-pressed={on}
                    className={cn(
                      'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left',
                      on ? 'bg-indigo-50/60 dark:bg-indigo-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50',
                    )}
                  >
                    <Thumb item={item} size="sm" />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-xs font-medium text-slate-700 dark:text-slate-200">
                        {item.name}
                      </span>
                      <span className="block text-[10px] text-slate-400">
                        {item.kind} · {humanSize(item.size)}
                        {item.uploadedAt ? ` · ${item.uploadedAt}` : ''}
                      </span>
                    </span>
                    {on && <Check className="h-3.5 w-3.5 shrink-0 text-indigo-600" strokeWidth={3} />}
                  </button>
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
items*MediaItem[]—
selectedstring[][]
onSelectedChange(next: string[]) => void—
multiplebooleantrue
onUpload(files: FileList) => void—Omit to hide the upload control entirely.
acceptstring—
classNamestring—