Web InnoventixFreeCode

File Manager Preview

Original · free

file preview panel

byWeb InnoventixReact + Tailwind
filemgrpreviewfilemanager
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/filemgr-preview.json
filemgr-preview.tsx
"use client";

import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";

type FileKind = "image" | "pdf" | "video" | "code" | "sheet" | "archive" | "note";

type Activity = { who: string; what: string; when: string };

type FileItem = {
  id: string;
  name: string;
  kind: FileKind;
  size: string;
  bytes: number;
  modified: string;
  owner: string;
  folder: string;
  detail: string;
  src?: string;
  duration?: number;
  pages?: number;
  tags: string[];
  activity: Activity[];
};

const FILES: FileItem[] = [
  {
    id: "f-hero",
    name: "hero-storyboard.webp",
    kind: "image",
    size: "2.4 MB",
    bytes: 2517801,
    modified: "Jul 14, 2026 · 09:41",
    owner: "Priya Raman",
    folder: "Northwind / 03-Design / Hero",
    detail: "2880 × 1620 · sRGB",
    src: "/img/gallery/g07.webp",
    tags: ["approved", "v4"],
    activity: [
      { who: "Priya Raman", what: "replaced the file with v4", when: "3 hours ago" },
      { who: "Dan Okafor", what: "left a comment on the sky gradient", when: "Yesterday · 16:02" },
      { who: "Priya Raman", what: "moved this into 03-Design", when: "Jul 11" },
    ],
  },
  {
    id: "f-brand",
    name: "brand-guidelines.pdf",
    kind: "pdf",
    size: "8.1 MB",
    bytes: 8493465,
    modified: "Jul 12, 2026 · 18:20",
    owner: "Dan Okafor",
    folder: "Northwind / 01-Brand",
    detail: "4 pages · PDF 1.7",
    pages: 4,
    tags: ["shared"],
    activity: [
      { who: "Dan Okafor", what: "shared a link with the client", when: "Jul 12" },
      { who: "Mei Tanaka", what: "downloaded this file", when: "Jul 12" },
    ],
  },
  {
    id: "f-checkout",
    name: "checkout-flow.mp4",
    kind: "video",
    size: "46.7 MB",
    bytes: 48967680,
    modified: "Jul 10, 2026 · 11:05",
    owner: "Mei Tanaka",
    folder: "Northwind / 04-Handoff",
    detail: "1920 × 1080 · H.264 · 30 fps",
    src: "/img/gallery/g22.webp",
    duration: 96,
    tags: ["review"],
    activity: [
      { who: "Mei Tanaka", what: "uploaded the screen recording", when: "Jul 10" },
      { who: "Priya Raman", what: "requested captions", when: "Jul 10" },
    ],
  },
  {
    id: "f-cart",
    name: "useCart.ts",
    kind: "code",
    size: "3.2 KB",
    bytes: 3277,
    modified: "Jul 15, 2026 · 22:47",
    owner: "Iker Solano",
    folder: "Northwind / 05-Code / hooks",
    detail: "TypeScript · 84 lines",
    tags: ["main"],
    activity: [
      { who: "Iker Solano", what: "committed “fix: clamp quantity at stock”", when: "11 hours ago" },
      { who: "Mei Tanaka", what: "approved the pull request", when: "Jul 15" },
    ],
  },
  {
    id: "f-forecast",
    name: "q3-forecast.csv",
    kind: "sheet",
    size: "94 KB",
    bytes: 96256,
    modified: "Jul 09, 2026 · 08:15",
    owner: "Dan Okafor",
    folder: "Northwind / 06-Ops",
    detail: "1,204 rows · 6 columns",
    tags: ["internal"],
    activity: [
      { who: "Dan Okafor", what: "re-exported from the billing dashboard", when: "Jul 09" },
    ],
  },
  {
    id: "f-press",
    name: "press-kit.zip",
    kind: "archive",
    size: "31.9 MB",
    bytes: 33449574,
    modified: "Jul 05, 2026 · 14:33",
    owner: "Priya Raman",
    folder: "Northwind / 01-Brand",
    detail: "6 items · Deflate",
    tags: ["public"],
    activity: [
      { who: "Priya Raman", what: "regenerated the archive", when: "Jul 05" },
      { who: "Link visitors", what: "downloaded this 24 times", when: "Last 30 days" },
    ],
  },
  {
    id: "f-notes",
    name: "interview-notes.md",
    kind: "note",
    size: "12 KB",
    bytes: 12288,
    modified: "Jul 16, 2026 · 07:58",
    owner: "Mei Tanaka",
    folder: "Northwind / 02-Research",
    detail: "Markdown · 6 min read",
    tags: ["draft"],
    activity: [
      { who: "Mei Tanaka", what: "added notes from session 07", when: "Today · 07:58" },
      { who: "Iker Solano", what: "starred this file", when: "Jul 16" },
    ],
  },
];

const KIND_LABEL: Record<FileKind, string> = {
  image: "Image",
  pdf: "Document",
  video: "Video",
  code: "Source",
  sheet: "Spreadsheet",
  archive: "Archive",
  note: "Note",
};

const KIND_CHIP: Record<FileKind, string> = {
  image: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
  pdf: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  video: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  code: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  sheet: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  archive: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  note: "bg-zinc-200 text-zinc-700 dark:bg-zinc-700/50 dark:text-zinc-200",
};

const PDF_PAGES: { title: string; lines: string[] }[] = [
  {
    title: "Northwind — Brand Guidelines",
    lines: [
      "Version 4.0 · July 2026",
      "Prepared for the Northwind marketing and product teams.",
      "These rules cover every surface we ship: web, packaging, and retail signage.",
    ],
  },
  {
    title: "01 — The mark",
    lines: [
      "The compass mark never rotates and never sits inside another shape.",
      "Minimum clear space equals the height of the letter N on all four sides.",
      "Smallest permitted size is 24 px on screen and 8 mm in print.",
    ],
  },
  {
    title: "02 — Colour",
    lines: [
      "Deep Harbour #10233F carries every primary surface.",
      "Signal Amber #F4A62A is reserved for a single call to action per screen.",
      "Body text runs at #1B1F24 on light and #E7E9EC on dark. No pure black.",
    ],
  },
  {
    title: "03 — Typography",
    lines: [
      "Headlines set in Söhne Kräftig, tracking −2%, never lighter than Medium.",
      "Body copy sets in Söhne Buch at 17/28 with a 68-character measure.",
      "Numerals are tabular in tables and proportional in running prose.",
    ],
  },
];

const SHEET_ROWS: string[][] = [
  ["Region", "Segment", "Q3 Plan", "Q3 Actual", "Δ"],
  ["EMEA", "Enterprise", "412,000", "438,900", "+6.5%"],
  ["EMEA", "Mid-market", "268,500", "251,300", "−6.4%"],
  ["AMER", "Enterprise", "690,000", "704,120", "+2.0%"],
  ["AMER", "Self-serve", "155,750", "182,640", "+17.3%"],
  ["APAC", "Enterprise", "301,200", "289,400", "−3.9%"],
  ["APAC", "Mid-market", "142,000", "160,880", "+13.3%"],
];

const ARCHIVE_ENTRIES: { path: string; size: string }[] = [
  { path: "logo/northwind-mark.svg", size: "14 KB" },
  { path: "logo/northwind-lockup.svg", size: "22 KB" },
  { path: "photography/store-lisbon.jpg", size: "11.4 MB" },
  { path: "photography/team-offsite.jpg", size: "9.8 MB" },
  { path: "boilerplate/company-profile.docx", size: "48 KB" },
  { path: "README.txt", size: "2 KB" },
];

type Tok = { s: string; c: keyof typeof TOK_CLASS };

const TOK_CLASS = {
  kw: "text-violet-600 dark:text-violet-400",
  str: "text-emerald-600 dark:text-emerald-400",
  fn: "text-sky-600 dark:text-sky-400",
  num: "text-amber-600 dark:text-amber-400",
  com: "text-zinc-400 dark:text-zinc-500",
  pl: "text-zinc-700 dark:text-zinc-300",
} as const;

const CODE_LINES: Tok[][] = [
  [{ s: "import", c: "kw" }, { s: " { useMemo } ", c: "pl" }, { s: "from", c: "kw" }, { s: " ", c: "pl" }, { s: '"react"', c: "str" }, { s: ";", c: "pl" }],
  [],
  [{ s: "// Quantity can never exceed what the warehouse actually holds.", c: "com" }],
  [{ s: "export function ", c: "kw" }, { s: "useCart", c: "fn" }, { s: "(lines: CartLine[], stock: StockMap) {", c: "pl" }],
  [{ s: "  const totals = ", c: "pl" }, { s: "useMemo", c: "fn" }, { s: "(() => {", c: "pl" }],
  [{ s: "    let subtotal = ", c: "pl" }, { s: "0", c: "num" }, { s: ";", c: "pl" }],
  [{ s: "    for (", c: "pl" }, { s: "const", c: "kw" }, { s: " line ", c: "pl" }, { s: "of", c: "kw" }, { s: " lines) {", c: "pl" }],
  [{ s: "      const qty = ", c: "pl" }, { s: "Math", c: "fn" }, { s: ".min(line.qty, stock[line.sku] ?? ", c: "pl" }, { s: "0", c: "num" }, { s: ");", c: "pl" }],
  [{ s: "      subtotal += qty * line.unitPrice;", c: "pl" }],
  [{ s: "    }", c: "pl" }],
  [{ s: "    return", c: "kw" }, { s: " { subtotal, tax: ", c: "pl" }, { s: "Math", c: "fn" }, { s: ".round(subtotal * ", c: "pl" }, { s: "0.2", c: "num" }, { s: ") };", c: "pl" }],
  [{ s: "  }, [lines, stock]);", c: "pl" }],
  [],
  [{ s: "  return", c: "kw" }, { s: " { totals, isEmpty: lines.length === ", c: "pl" }, { s: "0", c: "num" }, { s: " };", c: "pl" }],
  [{ s: "}", c: "pl" }],
];

const NOTE_BODY: { h?: string; p?: string; li?: string[] }[] = [
  { h: "Session 07 — Rita, warehouse lead, Porto" },
  {
    p: "Rita has run the Porto floor for nine years. She opened by saying the app is “not the problem, the printer is” — which reframed the whole session.",
  },
  { h: "What actually breaks" },
  {
    li: [
      "Labels reprint whenever a picker scans out of order, roughly 30 times a shift.",
      "She keeps a paper tally next to the terminal because the count resets at midnight.",
      "Two of six handhelds are dead by 3pm; charging cradles sit in the office, not the floor.",
    ],
  },
  { h: "Quote worth keeping" },
  {
    p: "“I don’t need a dashboard. I need the thing to tell me the box is wrong before it goes on the truck.”",
  },
];

function formatTime(seconds: number): string {
  const s = Math.max(0, Math.floor(seconds));
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}

function KindIcon({ kind, className }: { kind: FileKind; className?: string }) {
  const common = {
    className,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.6,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  switch (kind) {
    case "image":
      return (
        <svg {...common}>
          <rect x="3" y="4" width="18" height="16" rx="2.5" />
          <circle cx="8.5" cy="9.5" r="1.5" />
          <path d="m4 17 4.5-4.5a2 2 0 0 1 2.8 0L16 17M14 14l1.6-1.6a2 2 0 0 1 2.8 0L20 14" />
        </svg>
      );
    case "pdf":
      return (
        <svg {...common}>
          <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
          <path d="M14 3v5h5M9 13h6M9 17h4" />
        </svg>
      );
    case "video":
      return (
        <svg {...common}>
          <rect x="3" y="5" width="13" height="14" rx="2.5" />
          <path d="m16 10 5-3v10l-5-3z" />
        </svg>
      );
    case "code":
      return (
        <svg {...common}>
          <path d="m9 8-5 4 5 4M15 8l5 4-5 4M13.5 5l-3 14" />
        </svg>
      );
    case "sheet":
      return (
        <svg {...common}>
          <rect x="3" y="4" width="18" height="16" rx="2.5" />
          <path d="M3 9.5h18M9 9.5V20M3 15h18" />
        </svg>
      );
    case "archive":
      return (
        <svg {...common}>
          <path d="M3 7.5 5 4h14l2 3.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
          <path d="M3 7.5h18M12 4v7.5M10.5 14h3v3h-3z" />
        </svg>
      );
    case "note":
      return (
        <svg {...common}>
          <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
          <path d="M14 3v5h5M8.5 12.5h7M8.5 16h4.5" />
        </svg>
      );
  }
}

export default function FilemgrPreview() {
  const reduce = useReducedMotion();

  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<string>(FILES[0].id);
  const [tab, setTab] = useState<"details" | "activity">("details");
  const [starred, setStarred] = useState<string[]>(["f-cart"]);
  const [copied, setCopied] = useState(false);
  const [zoom, setZoom] = useState(100);
  const [page, setPage] = useState(1);
  const [time, setTime] = useState(0);
  const [playing, setPlaying] = useState(false);

  const optionRefs = useRef<Array<HTMLLIElement | null>>([]);
  const copyTimer = useRef<number | null>(null);

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return FILES;
    return FILES.filter(
      (f) =>
        f.name.toLowerCase().includes(q) ||
        f.owner.toLowerCase().includes(q) ||
        f.tags.some((t) => t.includes(q)),
    );
  }, [query]);

  // Selection is derived, never synced: if the filter hides the selected file the
  // first visible row takes over, so the panel is never left previewing nothing.
  const selected =
    filtered.find((f) => f.id === selectedId) ??
    filtered[0] ??
    FILES.find((f) => f.id === selectedId) ??
    FILES[0];
  const activeIndex = filtered.findIndex((f) => f.id === selected.id);
  const total = selected.duration ?? 0;

  useEffect(() => {
    if (!playing) return;
    const id = window.setTimeout(() => {
      const next = time + 0.2;
      if (next >= total) {
        setTime(total);
        setPlaying(false);
      } else {
        setTime(next);
      }
    }, 200);
    return () => window.clearTimeout(id);
  }, [playing, time, total]);

  useEffect(() => {
    return () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  function selectFile(id: string) {
    setSelectedId(id);
    setZoom(100);
    setPage(1);
    setTime(0);
    setPlaying(false);
  }

  function moveTo(next: number) {
    if (filtered.length === 0) return;
    const i = Math.max(0, Math.min(filtered.length - 1, next));
    selectFile(filtered[i].id);
    optionRefs.current[i]?.focus();
  }

  function togglePlay() {
    if (!playing && time >= total) setTime(0);
    setPlaying((p) => !p);
  }

  function onListKeyDown(e: ReactKeyboardEvent<HTMLUListElement>) {
    const i = activeIndex < 0 ? 0 : activeIndex;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      moveTo(i + 1);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      moveTo(i - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      moveTo(0);
    } else if (e.key === "End") {
      e.preventDefault();
      moveTo(filtered.length - 1);
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      moveTo(i);
    }
  }

  function onTabsKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    setTab((t) => (t === "details" ? "activity" : "details"));
  }

  async function copyLink() {
    const link = `https://files.northwind.studio/d/${selected.id}`;
    try {
      await navigator.clipboard.writeText(link);
    } catch {
      /* clipboard unavailable — still confirm the intent */
    }
    setCopied(true);
    if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
  }

  const isStarred = starred.includes(selected.id);
  function toggleStar() {
    setStarred((s) => (s.includes(selected.id) ? s.filter((x) => x !== selected.id) : [...s, selected.id]));
  }

  const stage: Variants = {
    hidden: { opacity: 0, y: reduce ? 0 : 8 },
    show: { opacity: 1, y: 0, transition: { duration: 0.28, ease: "easeOut" } },
    exit: { opacity: 0, y: reduce ? 0 : -8, transition: { duration: 0.16, ease: "easeIn" } },
  };

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";

  const progress = total > 0 ? (time / total) * 100 : 0;

  return (
    <section className="relative w-full overflow-hidden bg-white px-4 py-20 sm:px-6 md:py-28 dark:bg-zinc-950">
      <style>{`
        @keyframes fmp-caret { 0%, 45% { opacity: 1 } 50%, 95% { opacity: 0 } 100% { opacity: 1 } }
        @keyframes fmp-ping { 0% { transform: scale(1); opacity: .55 } 70%, 100% { transform: scale(2.4); opacity: 0 } }
        @keyframes fmp-sheen { 0% { transform: translateX(-120%) } 100% { transform: translateX(320%) } }
        .fmp-caret { animation: fmp-caret 1.15s steps(1) infinite; }
        .fmp-ping { animation: fmp-ping 2s cubic-bezier(0,0,.2,1) infinite; }
        .fmp-sheen { animation: fmp-sheen 4.5s ease-in-out infinite; }
        .fmp-grid {
          background-image:
            linear-gradient(45deg, rgba(113,113,122,.16) 25%, transparent 25%, transparent 75%, rgba(113,113,122,.16) 75%),
            linear-gradient(45deg, rgba(113,113,122,.16) 25%, transparent 25%, transparent 75%, rgba(113,113,122,.16) 75%);
          background-size: 16px 16px;
          background-position: 0 0, 8px 8px;
        }
        @media (prefers-reduced-motion: reduce) {
          .fmp-caret, .fmp-ping, .fmp-sheen { animation: none; }
        }
      `}</style>

      <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72">
        <div className="absolute left-1/2 top-[-6rem] h-72 w-[52rem] max-w-full -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-200 via-violet-200 to-sky-200 opacity-50 blur-3xl dark:opacity-[0.14]" />
      </div>

      <div className="mx-auto max-w-6xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/80 px-3 py-1 text-xs font-medium tracking-wide text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
            <span className="relative flex h-1.5 w-1.5">
              <span className="fmp-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
              <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
            </span>
            Northwind workspace · synced 2 min ago
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
            Open a file without opening an app
          </h2>
          <p className="mt-3 text-pretty text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            Every format previews in place — pages, frames, rows and diffs — so review stops meaning
            download, wait, and hope you have the right software.
          </p>
        </header>

        <div className="mt-12 grid gap-4 lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)]">
          {/* ---------------- File list ---------------- */}
          <aside className="flex flex-col rounded-2xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/40">
            <div className="relative">
              <svg
                aria-hidden
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={1.8}
                strokeLinecap="round"
                className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
              >
                <circle cx="11" cy="11" r="7" />
                <path d="m20 20-3.6-3.6" />
              </svg>
              <label htmlFor="fmp-search" className="sr-only">
                Filter files by name, owner or tag
              </label>
              <input
                id="fmp-search"
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Filter by name, owner, tag"
                className={`w-full rounded-lg border border-zinc-200 bg-white py-2 pl-9 pr-3 text-sm text-zinc-800 placeholder:text-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${focusRing}`}
              />
            </div>

            <p id="fmp-list-hint" className="mt-2 px-1 text-[11px] text-zinc-500 dark:text-zinc-500">
              Use ↑ ↓ to move through files, Home / End to jump.
            </p>

            {filtered.length === 0 ? (
              <p className="my-10 px-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
                Nothing matches “{query.trim()}”. Try a file extension or an owner’s name.
              </p>
            ) : (
              <ul
                role="listbox"
                aria-label="Files in Northwind workspace"
                aria-describedby="fmp-list-hint"
                onKeyDown={onListKeyDown}
                className="mt-2 flex-1 space-y-1 overflow-y-auto"
              >
                {filtered.map((f, i) => {
                  const active = f.id === selected.id;
                  return (
                    <li
                      key={f.id}
                      ref={(el) => {
                        optionRefs.current[i] = el;
                      }}
                      role="option"
                      aria-selected={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => selectFile(f.id)}
                      className={`group flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${focusRing} ${
                        active
                          ? "border-indigo-300 bg-white shadow-sm dark:border-indigo-500/40 dark:bg-zinc-900"
                          : "border-transparent hover:bg-white/80 dark:hover:bg-zinc-900/60"
                      }`}
                    >
                      <span
                        className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${KIND_CHIP[f.kind]}`}
                      >
                        <KindIcon kind={f.kind} className="h-[18px] w-[18px]" />
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="flex items-center gap-1.5">
                          <span className="truncate text-sm font-medium text-zinc-800 dark:text-zinc-100">
                            {f.name}
                          </span>
                          {starred.includes(f.id) ? (
                            <svg
                              aria-hidden
                              viewBox="0 0 24 24"
                              className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400"
                            >
                              <path d="m12 3.6 2.5 5.1 5.6.8-4 4 .9 5.6-5-2.6-5 2.6.9-5.6-4-4 5.6-.8z" />
                            </svg>
                          ) : null}
                        </span>
                        <span className="mt-0.5 block truncate text-xs text-zinc-500 dark:text-zinc-400">
                          {f.size} · {f.modified.split(" · ")[0]}
                        </span>
                      </span>
                      {active ? (
                        <span
                          aria-hidden
                          className="h-6 w-[3px] shrink-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
                        />
                      ) : null}
                    </li>
                  );
                })}
              </ul>
            )}

            <p className="mt-3 border-t border-zinc-200 px-1 pt-3 text-[11px] text-zinc-500 dark:border-zinc-800 dark:text-zinc-500">
              {filtered.length} of {FILES.length} files · 91.4 MB · Porto team
            </p>
          </aside>

          {/* ---------------- Preview panel ---------------- */}
          <div className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
            <div className="relative flex flex-wrap items-center gap-3 overflow-hidden border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
              <div
                aria-hidden
                className="pointer-events-none absolute inset-y-0 left-0 w-24 -skew-x-12 bg-gradient-to-r from-transparent via-indigo-500/[0.06] to-transparent fmp-sheen dark:via-indigo-300/[0.07]"
              />
              <span
                className={`relative flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${KIND_CHIP[selected.kind]}`}
              >
                <KindIcon kind={selected.kind} className="h-5 w-5" />
              </span>
              <div className="relative min-w-0 flex-1">
                <h3 className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                  {selected.name}
                </h3>
                <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
                  {KIND_LABEL[selected.kind]} · {selected.detail} · {selected.size}
                </p>
              </div>

              <div className="relative flex items-center gap-1.5">
                <button
                  type="button"
                  onClick={toggleStar}
                  aria-pressed={isStarred}
                  className={`inline-flex h-9 w-9 items-center justify-center rounded-lg border border-zinc-200 text-zinc-500 transition-colors hover:bg-zinc-50 hover:text-amber-500 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 ${focusRing}`}
                >
                  <span className="sr-only">{isStarred ? "Remove star from" : "Star"} {selected.name}</span>
                  <motion.svg
                    aria-hidden
                    viewBox="0 0 24 24"
                    fill={isStarred ? "currentColor" : "none"}
                    stroke="currentColor"
                    strokeWidth={1.6}
                    strokeLinejoin="round"
                    animate={reduce ? undefined : { scale: isStarred ? [1, 1.28, 1] : 1 }}
                    transition={{ duration: 0.32 }}
                    className={`h-[18px] w-[18px] ${isStarred ? "text-amber-500" : ""}`}
                  >
                    <path d="m12 3.6 2.5 5.1 5.6.8-4 4 .9 5.6-5-2.6-5 2.6.9-5.6-4-4 5.6-.8z" />
                  </motion.svg>
                </button>

                <button
                  type="button"
                  onClick={copyLink}
                  className={`inline-flex h-9 items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800 ${focusRing}`}
                >
                  {copied ? (
                    <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-emerald-500">
                      <path d="m5 12.5 4.5 4.5L19 7" />
                    </svg>
                  ) : (
                    <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
                      <path d="M10.5 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1" />
                      <path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1" />
                    </svg>
                  )}
                  <span className="hidden sm:inline">{copied ? "Link copied" : "Copy link"}</span>
                </button>

                <a
                  href={`https://files.northwind.studio/d/${selected.id}`}
                  target="_blank"
                  rel="noopener"
                  className={`inline-flex h-9 items-center gap-1.5 rounded-lg bg-zinc-900 px-3 text-xs font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white ${focusRing}`}
                >
                  <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
                    <path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14" />
                  </svg>
                  Download
                </a>
              </div>
            </div>

            {/* stage */}
            <div className="relative min-h-[20rem] bg-zinc-100 p-4 dark:bg-zinc-950/60">
              <AnimatePresence mode="wait">
                <motion.div
                  key={selected.id}
                  variants={stage}
                  initial="hidden"
                  animate="show"
                  exit="exit"
                  className="h-full"
                >
                  {selected.kind === "image" ? (
                    <div className="flex h-full flex-col gap-3">
                      <div className="fmp-grid relative flex h-72 items-center justify-center overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={selected.src}
                          alt="Storyboard frame: a wide hero shot of the Northwind lobby at dusk with the compass mark backlit above the desk"
                          loading="lazy"
                          draggable={false}
                          style={{ transform: `scale(${zoom / 100})` }}
                          className="max-h-full max-w-full origin-center object-contain transition-transform duration-150"
                        />
                      </div>
                      <div className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
                        <label htmlFor="fmp-zoom" className="text-xs font-medium text-zinc-600 dark:text-zinc-400">
                          Zoom
                        </label>
                        <input
                          id="fmp-zoom"
                          type="range"
                          min={50}
                          max={200}
                          step={5}
                          value={zoom}
                          onChange={(e) => setZoom(Number(e.target.value))}
                          aria-valuetext={`${zoom} percent`}
                          className={`h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-zinc-200 accent-indigo-600 dark:bg-zinc-700 dark:accent-indigo-400 ${focusRing}`}
                        />
                        <span className="w-12 text-right font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
                          {zoom}%
                        </span>
                        <button
                          type="button"
                          onClick={() => setZoom(100)}
                          className={`rounded-md border border-zinc-200 px-2 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 ${focusRing}`}
                        >
                          Reset
                        </button>
                      </div>
                    </div>
                  ) : null}

                  {selected.kind === "pdf" ? (
                    <div className="flex h-full flex-col gap-3">
                      <div className="flex h-72 items-center justify-center overflow-hidden rounded-xl border border-zinc-200 bg-zinc-200/60 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
                        <motion.article
                          key={page}
                          initial={reduce ? false : { opacity: 0, x: 12 }}
                          animate={{ opacity: 1, x: 0 }}
                          transition={{ duration: 0.22, ease: "easeOut" }}
                          className="flex h-full w-full max-w-sm flex-col rounded-md bg-white p-6 shadow-md ring-1 ring-black/5 dark:bg-zinc-100"
                        >
                          <h4 className="text-sm font-semibold text-zinc-900">{PDF_PAGES[page - 1].title}</h4>
                          <div className="mt-2 h-px w-10 bg-amber-400" />
                          <div className="mt-4 space-y-2.5">
                            {PDF_PAGES[page - 1].lines.map((l) => (
                              <p key={l} className="text-[11px] leading-relaxed text-zinc-600">
                                {l}
                              </p>
                            ))}
                          </div>
                          <p className="mt-auto pt-4 text-[10px] font-medium tracking-wide text-zinc-400">
                            NORTHWIND · {String(page).padStart(2, "0")}
                          </p>
                        </motion.article>
                      </div>
                      <div className="flex items-center justify-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
                        <button
                          type="button"
                          onClick={() => setPage((p) => Math.max(1, p - 1))}
                          disabled={page === 1}
                          className={`inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-200 text-zinc-600 hover:bg-zinc-50 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 ${focusRing}`}
                        >
                          <span className="sr-only">Previous page</span>
                          <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
                            <path d="m14 6-6 6 6 6" />
                          </svg>
                        </button>
                        <span aria-live="polite" className="min-w-[6.5rem] text-center text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-400">
                          Page {page} of {selected.pages}
                        </span>
                        <button
                          type="button"
                          onClick={() => setPage((p) => Math.min(PDF_PAGES.length, p + 1))}
                          disabled={page === PDF_PAGES.length}
                          className={`inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-200 text-zinc-600 hover:bg-zinc-50 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 ${focusRing}`}
                        >
                          <span className="sr-only">Next page</span>
                          <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
                            <path d="m10 6 6 6-6 6" />
                          </svg>
                        </button>
                      </div>
                    </div>
                  ) : null}

                  {selected.kind === "video" ? (
                    <div className="flex h-full flex-col gap-3">
                      <div className="relative flex h-72 items-center justify-center overflow-hidden rounded-xl border border-zinc-200 bg-zinc-900 dark:border-zinc-800">
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={selected.src}
                          alt="Video poster frame: the Northwind checkout screen mid-recording, with a cursor hovering the pay button"
                          loading="lazy"
                          draggable={false}
                          className="h-full w-full object-cover opacity-80"
                        />
                        <button
                          type="button"
                          onClick={togglePlay}
                          aria-pressed={playing}
                          className={`absolute inset-0 grid place-items-center ${focusRing} focus-visible:ring-offset-zinc-900`}
                        >
                          <span className="sr-only">{playing ? "Pause" : "Play"} checkout-flow.mp4</span>
                          <span className="grid h-16 w-16 place-items-center rounded-full bg-white/90 text-zinc-900 shadow-lg backdrop-blur transition-transform hover:scale-105">
                            {playing ? (
                              <svg aria-hidden viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6">
                                <rect x="7" y="5" width="3.5" height="14" rx="1" />
                                <rect x="13.5" y="5" width="3.5" height="14" rx="1" />
                              </svg>
                            ) : (
                              <svg aria-hidden viewBox="0 0 24 24" fill="currentColor" className="ml-1 h-6 w-6">
                                <path d="M8 5.5v13l11-6.5z" />
                              </svg>
                            )}
                          </span>
                        </button>
                        <div aria-hidden className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
                          <div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
                        </div>
                      </div>
                      <div className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
                        <span className="font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
                          {formatTime(time)}
                        </span>
                        <label htmlFor="fmp-scrub" className="sr-only">
                          Seek within checkout-flow.mp4
                        </label>
                        <input
                          id="fmp-scrub"
                          type="range"
                          min={0}
                          max={total}
                          step={1}
                          value={Math.floor(time)}
                          onChange={(e) => setTime(Number(e.target.value))}
                          aria-valuetext={`${formatTime(time)} of ${formatTime(total)}`}
                          className={`h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-zinc-200 accent-sky-600 dark:bg-zinc-700 dark:accent-sky-400 ${focusRing}`}
                        />
                        <span className="font-mono text-xs tabular-nums text-zinc-500 dark:text-zinc-500">
                          {formatTime(total)}
                        </span>
                      </div>
                    </div>
                  ) : null}

                  {selected.kind === "code" ? (
                    <div className="h-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
                      <div className="flex items-center justify-between border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
                        <span className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
                          src/hooks/useCart.ts · main
                        </span>
                        <span className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
                          +12 −4
                        </span>
                      </div>
                      <pre className="overflow-x-auto px-3 py-3 font-mono text-[11.5px] leading-[1.8]">
                        <code>
                          {CODE_LINES.map((line, i) => (
                            <span key={i} className="flex">
                              <span className="mr-3 w-6 shrink-0 select-none text-right text-zinc-300 dark:text-zinc-600">
                                {i + 1}
                              </span>
                              <span className="whitespace-pre">
                                {line.map((t, j) => (
                                  <span key={j} className={TOK_CLASS[t.c]}>
                                    {t.s}
                                  </span>
                                ))}
                                {i === CODE_LINES.length - 1 ? (
                                  <span aria-hidden className="fmp-caret ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] bg-indigo-500 dark:bg-indigo-400" />
                                ) : null}
                              </span>
                            </span>
                          ))}
                        </code>
                      </pre>
                    </div>
                  ) : null}

                  {selected.kind === "sheet" ? (
                    <div className="h-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
                      <div className="overflow-x-auto">
                        <table className="w-full min-w-[34rem] border-collapse text-left text-xs">
                          <caption className="sr-only">
                            Q3 forecast against actuals by region and segment
                          </caption>
                          <thead>
                            <tr>
                              {SHEET_ROWS[0].map((h) => (
                                <th
                                  key={h}
                                  scope="col"
                                  className="border-b border-zinc-200 bg-zinc-50 px-3 py-2.5 font-semibold text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300"
                                >
                                  {h}
                                </th>
                              ))}
                            </tr>
                          </thead>
                          <tbody>
                            {SHEET_ROWS.slice(1).map((row) => (
                              <tr key={row.join()} className="odd:bg-zinc-50/60 dark:odd:bg-zinc-800/25">
                                {row.map((cell, ci) => (
                                  <td
                                    key={ci}
                                    className={`border-b border-zinc-100 px-3 py-2.5 dark:border-zinc-800/70 ${
                                      ci >= 2 ? "text-right font-mono tabular-nums" : ""
                                    } ${
                                      ci === 4
                                        ? cell.startsWith("+")
                                          ? "font-medium text-emerald-600 dark:text-emerald-400"
                                          : "font-medium text-rose-600 dark:text-rose-400"
                                        : "text-zinc-700 dark:text-zinc-300"
                                    }`}
                                  >
                                    {cell}
                                  </td>
                                ))}
                              </tr>
                            ))}
                          </tbody>
                        </table>
                      </div>
                      <p className="border-t border-zinc-200 px-3 py-2 text-[11px] text-zinc-500 dark:border-zinc-800 dark:text-zinc-500">
                        Showing rows 1–6 of 1,204 · delimiter “,” · UTF-8
                      </p>
                    </div>
                  ) : null}

                  {selected.kind === "archive" ? (
                    <div className="h-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
                      <p className="border-b border-zinc-200 px-3 py-2 text-[11px] font-medium text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
                        Archive contents · 6 items · nothing extracted yet
                      </p>
                      <ul className="divide-y divide-zinc-100 dark:divide-zinc-800/70">
                        {ARCHIVE_ENTRIES.map((entry) => (
                          <li key={entry.path} className="flex items-center gap-3 px-3 py-2.5">
                            <svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 shrink-0 text-zinc-400">
                              <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
                              <path d="M14 3v5h5" />
                            </svg>
                            <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-zinc-700 dark:text-zinc-300">
                              {entry.path}
                            </span>
                            <span className="shrink-0 font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-500">
                              {entry.size}
                            </span>
                          </li>
                        ))}
                      </ul>
                    </div>
                  ) : null}

                  {selected.kind === "note" ? (
                    <div className="h-full max-h-[19rem] overflow-y-auto rounded-xl border border-zinc-200 bg-white px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900">
                      {NOTE_BODY.map((block, i) => {
                        if (block.h)
                          return (
                            <h4
                              key={i}
                              className="mb-2 mt-4 text-[13px] font-semibold text-zinc-900 first:mt-0 dark:text-zinc-100"
                            >
                              {block.h}
                            </h4>
                          );
                        if (block.li)
                          return (
                            <ul key={i} className="mb-2 space-y-1.5">
                              {block.li.map((item) => (
                                <li key={item} className="flex gap-2 text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
                                  <span aria-hidden className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-indigo-400" />
                                  {item}
                                </li>
                              ))}
                            </ul>
                          );
                        return (
                          <p key={i} className="mb-2 text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
                            {block.p}
                          </p>
                        );
                      })}
                    </div>
                  ) : null}
                </motion.div>
              </AnimatePresence>
            </div>

            {/* tabs */}
            <div className="border-t border-zinc-200 dark:border-zinc-800">
              <div
                role="tablist"
                aria-label="File information"
                onKeyDown={onTabsKeyDown}
                className="flex gap-1 border-b border-zinc-200 px-3 pt-2 dark:border-zinc-800"
              >
                {(["details", "activity"] as const).map((t) => (
                  <button
                    key={t}
                    type="button"
                    role="tab"
                    id={`fmp-tab-${t}`}
                    aria-selected={tab === t}
                    aria-controls={`fmp-panel-${t}`}
                    tabIndex={tab === t ? 0 : -1}
                    onClick={() => setTab(t)}
                    className={`relative rounded-t-md px-3 pb-2.5 pt-1.5 text-xs font-medium capitalize transition-colors ${focusRing} ${
                      tab === t
                        ? "text-zinc-900 dark:text-zinc-50"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                    }`}
                  >
                    {t}
                    {tab === t ? (
                      <motion.span
                        layoutId="fmp-tab-underline"
                        className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400"
                        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
                      />
                    ) : null}
                  </button>
                ))}
              </div>

              <div
                role="tabpanel"
                id="fmp-panel-details"
                aria-labelledby="fmp-tab-details"
                hidden={tab !== "details"}
                tabIndex={0}
                className={`px-4 py-4 ${focusRing}`}
              >
                <dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
                  {[
                    ["Location", selected.folder],
                    ["Owner", selected.owner],
                    ["Modified", selected.modified],
                    ["Exact size", `${selected.bytes.toLocaleString("en-US")} bytes`],
                  ].map(([k, v]) => (
                    <div key={k} className="min-w-0">
                      <dt className="text-[11px] uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
                        {k}
                      </dt>
                      <dd className="mt-0.5 truncate text-xs text-zinc-700 dark:text-zinc-300">{v}</dd>
                    </div>
                  ))}
                  <div className="sm:col-span-2">
                    <dt className="text-[11px] uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
                      Tags
                    </dt>
                    <dd className="mt-1.5 flex flex-wrap gap-1.5">
                      {selected.tags.map((t) => (
                        <span
                          key={t}
                          className="rounded-full border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:border-zinc-700 dark:text-zinc-300"
                        >
                          {t}
                        </span>
                      ))}
                    </dd>
                  </div>
                </dl>
              </div>

              <div
                role="tabpanel"
                id="fmp-panel-activity"
                aria-labelledby="fmp-tab-activity"
                hidden={tab !== "activity"}
                tabIndex={0}
                className={`px-4 py-4 ${focusRing}`}
              >
                <ol className="space-y-3.5">
                  {selected.activity.map((a) => (
                    <li key={a.who + a.when} className="flex gap-3">
                      <span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-zinc-100 text-[10px] font-semibold text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                        {a.who
                          .split(" ")
                          .map((w) => w[0])
                          .join("")
                          .slice(0, 2)}
                      </span>
                      <p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
                        <span className="font-medium text-zinc-800 dark:text-zinc-200">{a.who}</span> {a.what}
                        <span className="block text-[11px] text-zinc-400 dark:text-zinc-500">{a.when}</span>
                      </p>
                    </li>
                  ))}
                </ol>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →