166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
// components/utilities/files/FileBrowserPanel.tsx
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ChevronRight, Home, Loader2 } from "lucide-react";
|
|
import FilesTable from "./FilesTable";
|
|
import FilePreview from "./FilePreview";
|
|
import { FsEntry, list, parentDir, download, SortDir, SortKey } from "./api";
|
|
|
|
export default function FileBrowserPanel() {
|
|
const [cwd, setCwd] = useState<string>("/");
|
|
const [entries, setEntries] = useState<FsEntry[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [selected, setSelected] = useState<FsEntry | null>(null);
|
|
|
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
|
|
const refresh = useCallback(async (path: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await list(path);
|
|
setCwd(res.cwd || path || "/");
|
|
setEntries(res.entries || []);
|
|
// Clear selection if it no longer exists
|
|
if (selected && !res.entries.find(e => e.path === selected.path)) setSelected(null);
|
|
} catch (e: any) {
|
|
setError(e?.message || String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selected]);
|
|
|
|
useEffect(() => { refresh(cwd); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
|
|
|
const crumbs = useMemo(() => {
|
|
const norm = cwd.replace(/\\/g, "/");
|
|
const segs = norm.split("/").filter(Boolean);
|
|
const out: { label: string; path: string }[] = [{ label: "root", path: "/" }];
|
|
let acc = "";
|
|
for (const s of segs) {
|
|
acc += "/" + s;
|
|
out.push({ label: s, path: acc || "/" });
|
|
}
|
|
return out;
|
|
}, [cwd]);
|
|
|
|
function openEntry(e: FsEntry) {
|
|
if (e.isDir) {
|
|
setSelected(null);
|
|
refresh(e.path);
|
|
} else {
|
|
setSelected(e);
|
|
}
|
|
}
|
|
|
|
function onSort(k: SortKey) {
|
|
if (k === sortKey) {
|
|
setSortDir(d => (d === "asc" ? "desc" : "asc"));
|
|
} else {
|
|
setSortKey(k);
|
|
setSortDir("asc");
|
|
}
|
|
}
|
|
|
|
async function goUp() {
|
|
const p = parentDir(cwd);
|
|
setSelected(null);
|
|
await refresh(p);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Top bar */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* Breadcrumbs */}
|
|
<nav className="flex items-center gap-1 text-sm">
|
|
{crumbs.map((c, i) => (
|
|
<span key={c.path} className="inline-flex items-center">
|
|
{i === 0 ? (
|
|
<button
|
|
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 hover:bg-muted"
|
|
onClick={() => { setSelected(null); refresh("/"); }}
|
|
title="Go to root"
|
|
>
|
|
<Home className="w-3.5 h-3.5" />
|
|
root
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="rounded-md border px-2 py-0.5 hover:bg-muted"
|
|
onClick={() => { setSelected(null); refresh(c.path); }}
|
|
title={c.path}
|
|
>
|
|
{c.label}
|
|
</button>
|
|
)}
|
|
{i < crumbs.length - 1 && <ChevronRight className="w-3.5 h-3.5 mx-1 opacity-60" />}
|
|
</span>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => refresh(cwd)}
|
|
className="rounded-md border px-2 py-1 text-sm hover:bg-muted inline-flex items-center gap-2"
|
|
title="Refresh"
|
|
disabled={loading}
|
|
>
|
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Refresh
|
|
</button>
|
|
<button
|
|
onClick={goUp}
|
|
className="rounded-md border px-2 py-1 text-sm hover:bg-muted"
|
|
title="Up one level"
|
|
disabled={cwd === "/"}
|
|
>
|
|
Up
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="rounded-md border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-column layout */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div className="lg:col-span-2">
|
|
{loading ? (
|
|
<div className="rounded-md border p-6 text-sm text-zinc-400 flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
|
</div>
|
|
) : (
|
|
<FilesTable
|
|
entries={entries}
|
|
sortKey={sortKey}
|
|
sortDir={sortDir}
|
|
onSort={onSort}
|
|
onOpen={openEntry}
|
|
onDownload={(e) => download(e.path)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="lg:col-span-1">
|
|
<div className="rounded-md border p-3">
|
|
<div className="text-sm text-zinc-400 mb-2">Preview</div>
|
|
{selected ? (
|
|
<FilePreview path={selected.path} mime={selected.mime} name={selected.name} />
|
|
) : (
|
|
<div className="text-sm text-zinc-500">Select a file to preview.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|