"use client"; import React, { useEffect, useMemo, useState, useCallback, useRef, MutableRefObject, } 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[] } | { path: string; entries: FileItem[] } | { items?: FileItem[]; entries?: FileItem[]; path?: string }; const BASE = (process.env.NEXT_PUBLIC_FILE_API_BASE_URL || "").replace(/\/$/, "") || ""; 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]}`; } const LS_KEY = "fs.split.left"; // px export default function FileBrowserPanel() { const [path, setPath] = useState("/"); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [previewHref, setPreviewHref] = useState(null); // ───────────────── Splitter state const containerRef = useRef(null); const [leftPx, setLeftPx] = useState(() => { if (typeof window === "undefined") return 640; // SSR default const v = Number(localStorage.getItem(LS_KEY)); return Number.isFinite(v) && v > 0 ? v : 720; // good desktop start }); // Enforce bounds on resize / first mount const clampLeft = useCallback((want: number) => { const host = containerRef.current; if (!host) return want; const total = host.clientWidth || 0; const MIN_LEFT = 520; // so Name + columns fit const MIN_RIGHT = 360; // keep preview usable const maxLeft = Math.max(320, total - MIN_RIGHT); return Math.min(Math.max(want, MIN_LEFT), maxLeft); }, []); useEffect(() => { const handle = () => setLeftPx((v) => clampLeft(v)); window.addEventListener("resize", handle); return () => window.removeEventListener("resize", handle); }, [clampLeft]); useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(LS_KEY, String(leftPx)); } }, [leftPx]); // ───────────────── Data const urlList = useMemo(() => { const p = encodeURIComponent(path || "/"); return `${BASE}/api/files/list?path=${p}`; }, [path]); const urlDownload = useCallback((p: string) => { const qp = encodeURIComponent(p || "/"); return `${BASE}/api/files/download?path=${qp}`; }, []); 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 fetch(urlList, { method: "GET", credentials: "include", 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"); 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); } }, [urlList]); useEffect(() => { fetchList(); }, [fetchList]); // ───────────────── Actions const onOpen = (it: FileItem) => { if (it.type === "dir") { setPath((p) => joinPath(p, it.name)); } else { setPreviewHref(urlRaw(joinPath(path, it.name))); } }; const onUp = () => setPath((p) => parentPath(p)); const onHome = () => setPath("/"); const onDownload = (it: FileItem) => { const href = urlDownload(joinPath(path, it.name)); const a = document.createElement("a"); a.href = href; a.download = it.name; document.body.appendChild(a); a.click(); a.remove(); }; // ───────────────── Splitter drag handlers const draggingRef = useRef(false); const startXRef = useRef(0); const startLeftRef = useRef(0); const onSplitDown = (e: React.MouseEvent | React.TouchEvent) => { draggingRef.current = true; startXRef.current = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; startLeftRef.current = leftPx; document.addEventListener("mousemove", onSplitMove as any); document.addEventListener("mouseup", onSplitUp as any); document.addEventListener("touchmove", onSplitMove as any, { passive: false }); document.addEventListener("touchend", onSplitUp as any); }; const onSplitMove = (e: MouseEvent | TouchEvent) => { if (!draggingRef.current) return; const clientX = e instanceof TouchEvent ? e.touches[0]?.clientX ?? startXRef.current : e.clientX; const dx = clientX - startXRef.current; setLeftPx((prev) => clampLeft(startLeftRef.current + dx)); if (e.cancelable) e.preventDefault(); }; const onSplitUp = () => { draggingRef.current = false; document.removeEventListener("mousemove", onSplitMove as any); document.removeEventListener("mouseup", onSplitUp as any); document.removeEventListener("touchmove", onSplitMove as any); document.removeEventListener("touchend", onSplitUp as any); }; return (
{/* Small styles */} {/* Toolbar */}
{path || "/"}
{/* Split container */}
{/* LEFT: table */}
{/* Header: name flexes; others compact so Actions never truncates */}
Name
Type
Size
Modified
Actions
{loading ? (
Loading…
) : error ? (
Error: {error}
) : items.length === 0 ? (
Empty folder.
) : ( items.map((it) => (
{it.type}
{formatSize(it.size)}
{it.mtimeMs ? new Date(it.mtimeMs).toLocaleString() : "—"}
{it.type === "file" && ( )}
)) )}
{/* GUTTER */}
{/* RIGHT: preview */}
Preview
{previewHref ? (