file browser call fix

This commit is contained in:
makearmy 2025-10-15 21:26:56 -04:00
parent 4aebd80a5d
commit 448bb8e034

View file

@ -1,164 +1,240 @@
// components/utilities/files/FileBrowserPanel.tsx
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState, useCallback } from "react";
import { ChevronRight, Home, Loader2 } from "lucide-react"; import { Loader2, Download, Folder, FileText, RefreshCw, ArrowUp, Home } from "lucide-react";
import FilesTable from "./FilesTable";
import FilePreview from "./FilePreview"; type FileItem = {
import { FsEntry, list, parentDir, download, SortDir, SortKey } from "./api"; name: string;
type: "dir" | "file";
size?: number;
mtimeMs?: number;
};
type ListResponse =
| { path: string; items: FileItem[] } // current API shape
| { path: string; entries: FileItem[] } // legacy fallback
| { items?: FileItem[]; entries?: FileItem[]; path?: string };
const BASE =
(process.env.NEXT_PUBLIC_FILE_API_BASE_URL || "").replace(/\/$/, "") || ""; // same-origin fallback
function joinPath(a: string, b: string) {
if (!a || a === "/") return b.startsWith("/") ? b : `/${b}`;
return `${a.replace(/\/$/, "")}/${b.replace(/^\//, "")}`;
}
function parentPath(path: string) {
if (!path || path === "/") return "/";
const parts = path.replace(/\/+$/, "").split("/");
parts.pop();
const p = parts.join("/");
return p === "" ? "/" : p;
}
function formatSize(bytes?: number) {
if (bytes == null) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
let u = 0;
while (v >= 1024 && u < units.length - 1) {
v /= 1024;
u++;
}
return `${v.toFixed(u ? 1 : 0)} ${units[u]}`;
}
export default function FileBrowserPanel() { export default function FileBrowserPanel() {
const [cwd, setCwd] = useState<string>("/"); const [path, setPath] = useState<string>("/");
const [entries, setEntries] = useState<FsEntry[]>([]); const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [previewHref, setPreviewHref] = useState<string | null>(null);
const [selected, setSelected] = useState<FsEntry | null>(null); const urlList = useMemo(() => {
const p = encodeURIComponent(path || "/");
return `${BASE}/api/files/list?path=${p}`;
}, [path]);
const [sortKey, setSortKey] = useState<SortKey>("name"); const urlDownload = useCallback((p: string) => {
const [sortDir, setSortDir] = useState<SortDir>("asc"); const qp = encodeURIComponent(p || "/");
return `${BASE}/api/files/download?path=${qp}`;
}, []);
const refresh = useCallback(async (path: string) => { const urlRaw = useCallback((p: string) => {
const qp = encodeURIComponent(p || "/");
return `${BASE}/api/files/raw?path=${qp}`;
}, []);
const fetchList = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setPreviewHref(null);
try { try {
const res = await list(path); const res = await fetch(urlList, {
setCwd(res.cwd || path || "/"); method: "GET",
setEntries(res.entries || []); credentials: "include", // ensure cookies flow past middleware
// Clear selection if it no longer exists headers: { Accept: "application/json" },
if (selected && !res.entries.find(e => e.path === selected.path)) setSelected(null); cache: "no-store",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
const json: ListResponse = await res.json();
const arr = (json as any).items || (json as any).entries || [];
if (!Array.isArray(arr)) throw new Error("Malformed list response");
// Sort: dirs first, then files, alpha
arr.sort((a: FileItem, b: FileItem) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
setItems(arr);
} catch (e: any) { } catch (e: any) {
setError(e?.message || String(e)); setError(e?.message || String(e));
setItems([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selected]); }, [urlList]);
useEffect(() => { refresh(cwd); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); useEffect(() => {
fetchList();
}, [fetchList]);
const crumbs = useMemo(() => { const onOpen = (it: FileItem) => {
const norm = cwd.replace(/\\/g, "/"); if (it.type === "dir") {
const segs = norm.split("/").filter(Boolean); setPath((p) => joinPath(p, it.name));
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 { } else {
setSelected(e); // try to preview; if the file is not previewable, user can still download
} setPreviewHref(urlRaw(joinPath(path, it.name)));
} }
};
function onSort(k: SortKey) { const onUp = () => setPath((p) => parentPath(p));
if (k === sortKey) { const onHome = () => setPath("/");
setSortDir(d => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(k);
setSortDir("asc");
}
}
async function goUp() { const onDownload = (it: FileItem) => {
const p = parentDir(cwd); const href = urlDownload(joinPath(path, it.name));
setSelected(null); // create an anchor to download
await refresh(p); const a = document.createElement("a");
} a.href = href;
a.download = it.name;
document.body.appendChild(a);
a.click();
a.remove();
};
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Top bar */} {/* toolbar */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex 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 <button
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 hover:bg-muted" onClick={onHome}
onClick={() => { setSelected(null); refresh("/"); }} className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
title="Go to root" title="Root"
> >
<Home className="w-3.5 h-3.5" /> <Home className="h-4 w-4" /> root
root
</button> </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"> <div className="ml-auto flex items-center gap-2">
<button <button
onClick={() => refresh(cwd)} onClick={fetchList}
className="rounded-md border px-2 py-1 text-sm hover:bg-muted inline-flex items-center gap-2" className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
title="Refresh" title="Refresh"
disabled={loading}
> >
{loading && <Loader2 className="w-4 h-4 animate-spin" />} <RefreshCw className="h-4 w-4" /> Refresh
Refresh
</button> </button>
<button <button
onClick={goUp} onClick={onUp}
className="rounded-md border px-2 py-1 text-sm hover:bg-muted" className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
title="Up one level" title="Up one folder"
disabled={cwd === "/"}
> >
Up <ArrowUp className="h-4 w-4" /> Up
</button> </button>
</div> </div>
</div> </div>
{/* Error */} {/* path crumb */}
{error && ( <div className="text-xs text-muted-foreground select-all">{path}</div>
<div className="rounded-md border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
{error}
</div>
)}
{/* Two-column layout */} {/* grid: table + preview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-[1fr,420px] gap-4">
<div className="lg:col-span-2"> <div className="rounded-md border overflow-hidden">
<div className="grid grid-cols-[minmax(220px,1fr),90px,100px,130px,90px] items-center bg-muted px-3 py-2 text-sm font-medium">
<div>Name</div>
<div>Type</div>
<div>Size</div>
<div>Modified</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y">
{loading ? ( {loading ? (
<div className="rounded-md border p-6 text-sm text-zinc-400 flex items-center gap-2"> <div className="p-6 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Loading <Loader2 className="h-4 w-4 animate-spin" /> Loading
</div> </div>
) : error ? (
<div className="p-6 text-sm text-rose-400">Error: {error}</div>
) : items.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground">Empty folder.</div>
) : ( ) : (
<FilesTable items.map((it) => (
entries={entries} <div
sortKey={sortKey} key={it.name + it.type}
sortDir={sortDir} className="grid grid-cols-[minmax(220px,1fr),90px,100px,130px,90px] items-center px-3 py-2 text-sm hover:bg-muted/50"
onSort={onSort} >
onOpen={openEntry} <button
onDownload={(e) => download(e.path)} className="text-left inline-flex items-center gap-2 hover:underline"
/> onClick={() => onOpen(it)}
title={it.type === "dir" ? "Open folder" : "Preview"}
>
{it.type === "dir" ? (
<Folder className="h-4 w-4" />
) : (
<FileText className="h-4 w-4" />
)} )}
<span className="truncate">{it.name}</span>
</button>
<div className="uppercase tracking-wide text-[11px] text-muted-foreground">
{it.type}
</div>
<div className="text-muted-foreground">{formatSize(it.size)}</div>
<div className="text-muted-foreground">
{it.mtimeMs ? new Date(it.mtimeMs).toLocaleString() : "—"}
</div>
<div className="text-right">
{it.type === "file" && (
<button
onClick={() => onDownload(it)}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-muted"
title="Download"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
)}
</div>
</div>
))
)}
</div>
</div> </div>
<div className="lg:col-span-1"> {/* preview */}
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<div className="text-sm text-zinc-400 mb-2">Preview</div> <div className="mb-2 text-sm font-medium">Preview</div>
{selected ? ( {previewHref ? (
<FilePreview path={selected.path} mime={selected.mime} name={selected.name} /> <div className="border rounded overflow-hidden">
) : ( {/* let the browser try its best; PDFs/images will render inline */}
<div className="text-sm text-zinc-500">Select a file to preview.</div> <iframe
)} key={previewHref}
src={previewHref}
className="w-full h-[420px]"
title="Preview"
/>
</div> </div>
) : (
<div className="text-sm text-muted-foreground">Select a file to preview.</div>
)}
</div> </div>
</div> </div>
</div> </div>