File Manager Preview
Original · freefile preview panel
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"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
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 quoteSimilar components
Browse all →
File Manager List
Originalfile manager list view

File Manager Grid
Originalfile manager grid view

File Manager Upload
Originalupload dropzone with progress

File Manager Breadcrumb
Originalfile path breadcrumb with toolbar

File Manager Tree
Originalfolder tree sidebar

File Manager Storage
Originalstorage usage widget

File Manager Recent
Originalrecent files list

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

