diff --git a/components/utilities/files/FileBrowserPanel.tsx b/components/utilities/files/FileBrowserPanel.tsx index 546368c6..4c5db41a 100644 --- a/components/utilities/files/FileBrowserPanel.tsx +++ b/components/utilities/files/FileBrowserPanel.tsx @@ -1,166 +1,242 @@ -// 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"; +import React, { useEffect, useMemo, useState, useCallback } from "react"; +import { Loader2, Download, Folder, FileText, RefreshCw, ArrowUp, Home } from "lucide-react"; + +type FileItem = { + 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() { - const [cwd, setCwd] = useState("/"); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); + const [path, setPath] = useState("/"); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [previewHref, setPreviewHref] = useState(null); - const [selected, setSelected] = useState(null); + const urlList = useMemo(() => { + const p = encodeURIComponent(path || "/"); + return `${BASE}/api/files/list?path=${p}`; + }, [path]); - const [sortKey, setSortKey] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); + const urlDownload = useCallback((p: string) => { + 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); setError(null); + setPreviewHref(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); + const res = await fetch(urlList, { + method: "GET", + credentials: "include", // ensure cookies flow past middleware + headers: { Accept: "application/json" }, + 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) { setError(e?.message || String(e)); + setItems([]); } finally { setLoading(false); } - }, [selected]); + }, [urlList]); - useEffect(() => { refresh(cwd); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); + useEffect(() => { + fetchList(); + }, [fetchList]); - 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); + const onOpen = (it: FileItem) => { + if (it.type === "dir") { + setPath((p) => joinPath(p, it.name)); } 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) { - if (k === sortKey) { - setSortDir(d => (d === "asc" ? "desc" : "asc")); - } else { - setSortKey(k); - setSortDir("asc"); - } - } + const onUp = () => setPath((p) => parentPath(p)); + const onHome = () => setPath("/"); - async function goUp() { - const p = parentDir(cwd); - setSelected(null); - await refresh(p); - } + const onDownload = (it: FileItem) => { + const href = urlDownload(joinPath(path, it.name)); + // create an anchor to download + const a = document.createElement("a"); + a.href = href; + a.download = it.name; + document.body.appendChild(a); + a.click(); + a.remove(); + }; return ( -
- {/* Top bar */} -
- {/* Breadcrumbs */} - - +
+ {/* toolbar */} +
+
- {/* Error */} - {error && ( -
- {error} -
- )} + {/* path crumb */} +
{path}
- {/* Two-column layout */} -
-
+ {/* grid: table + preview */} +
+
+
+
Name
+
Type
+
Size
+
Modified
+
Actions
+
+ +
{loading ? ( -
- Loading… +
+ Loading…
+ ) : error ? ( +
Error: {error}
+ ) : items.length === 0 ? ( +
Empty folder.
) : ( - download(e.path)} - /> + items.map((it) => ( +
+ +
+ {it.type} +
+
{formatSize(it.size)}
+
+ {it.mtimeMs ? new Date(it.mtimeMs).toLocaleString() : "—"} +
+
+ {it.type === "file" && ( + + )} +
+
+ )) )}
+
-
+ {/* preview */}
-
Preview
- {selected ? ( - +
Preview
+ {previewHref ? ( +
+ {/* let the browser try its best; PDFs/images will render inline */} +