file browser call fix
This commit is contained in:
parent
4aebd80a5d
commit
448bb8e034
1 changed files with 192 additions and 116 deletions
|
|
@ -1,166 +1,242 @@
|
||||||
// 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 */}
|
<button
|
||||||
<nav className="flex items-center gap-1 text-sm">
|
onClick={onHome}
|
||||||
{crumbs.map((c, i) => (
|
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||||
<span key={c.path} className="inline-flex items-center">
|
title="Root"
|
||||||
{i === 0 ? (
|
>
|
||||||
<button
|
<Home className="h-4 w-4" /> root
|
||||||
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 hover:bg-muted"
|
</button>
|
||||||
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">
|
<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 */}
|
||||||
|
<iframe
|
||||||
|
key={previewHref}
|
||||||
|
src={previewHref}
|
||||||
|
className="w-full h-[420px]"
|
||||||
|
title="Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-zinc-500">Select a file to preview.</div>
|
<div className="text-sm text-muted-foreground">Select a file to preview.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue