file browser small UI improvements
This commit is contained in:
parent
a6971f4535
commit
68c5f602f9
1 changed files with 66 additions and 221 deletions
|
|
@ -1,37 +1,15 @@
|
|||
"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;
|
||||
};
|
||||
import React, { useCallback, useEffect, useMemo, useState } 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(/\/$/, "") || "";
|
||||
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}`;
|
||||
|
|
@ -49,14 +27,15 @@ function formatSize(bytes?: number) {
|
|||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = bytes;
|
||||
let u = 0;
|
||||
while (v >= 1024 && u < units.length - 1) {
|
||||
v /= 1024;
|
||||
u++;
|
||||
}
|
||||
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
|
||||
function formatDate(ms?: number) {
|
||||
if (!ms) return "—";
|
||||
const d = new Date(ms);
|
||||
// date only, short & compact
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "numeric", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function FileBrowserPanel() {
|
||||
const [path, setPath] = useState<string>("/");
|
||||
|
|
@ -65,38 +44,6 @@ export default function FileBrowserPanel() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewHref, setPreviewHref] = useState<string | null>(null);
|
||||
|
||||
// ───────────────── Splitter state
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [leftPx, setLeftPx] = useState<number>(() => {
|
||||
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}`;
|
||||
|
|
@ -115,23 +62,12 @@ export default function FileBrowserPanel() {
|
|||
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 res = await fetch(urlList, { method: "GET", credentials: "include", headers: { Accept: "application/json" }, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`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);
|
||||
});
|
||||
arr.sort((a: FileItem, b: FileItem) => (a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)));
|
||||
setItems(arr);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
|
|
@ -141,104 +77,38 @@ export default function FileBrowserPanel() {
|
|||
}
|
||||
}, [urlList]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
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)));
|
||||
}
|
||||
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);
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Small styles */}
|
||||
{/* keep typography consistent */}
|
||||
<style>{`
|
||||
.split-gutter {
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
}
|
||||
.split-gutter:hover { background: rgba(255,255,255,0.06); }
|
||||
@media (max-width: 1023px) {
|
||||
.split-wrap { display:block !important; }
|
||||
.split-gutter { display:none; }
|
||||
}
|
||||
.fs-cell{ font-size: 13.5px; line-height: 1.3; }
|
||||
.fs-tight{ letter-spacing: .01em; }
|
||||
`}</style>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Root"
|
||||
>
|
||||
<button onClick={onHome} className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted" title="Root">
|
||||
<Home className="h-4 w-4" /> root
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchList}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Refresh"
|
||||
>
|
||||
<button onClick={fetchList} className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted" title="Refresh">
|
||||
<RefreshCw className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={onUp}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Up one folder"
|
||||
>
|
||||
<button onClick={onUp} className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted" title="Up one folder">
|
||||
<ArrowUp className="h-4 w-4" /> Up
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -246,28 +116,25 @@ export default function FileBrowserPanel() {
|
|||
|
||||
<div className="text-xs text-muted-foreground select-all">{path || "/"}</div>
|
||||
|
||||
{/* Split container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="split-wrap flex w-full gap-0 rounded-md border"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{/* LEFT: table */}
|
||||
<div
|
||||
className="min-w-[520px] border-r"
|
||||
style={{ width: leftPx, overflow: "hidden" }}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
{/* Header: name flexes; others compact so Actions never truncates */}
|
||||
<div className="grid grid-cols-[minmax(320px,1fr),90px,96px,184px,120px] items-center bg-muted px-3 py-2 text-sm font-medium">
|
||||
{/* Two-column layout: left auto, right fixed min (no horizontal scroll in left) */}
|
||||
<div className="grid grid-cols-[minmax(640px,1fr)_minmax(420px,42vw)] gap-3">
|
||||
{/* LEFT: table (no horizontal scrolling; columns sized to fit) */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid items-center bg-muted px-3 py-2 fs-cell fs-tight"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
// Name grows; Type/Size compact; Date compact; Actions fixed
|
||||
"minmax(280px,1fr) 64px 84px 112px 118px",
|
||||
}}>
|
||||
<div>Name</div>
|
||||
<div>Type</div>
|
||||
<div>Size</div>
|
||||
<div>Modified</div>
|
||||
<div className="text-center">Type</div>
|
||||
<div className="text-right">Size</div>
|
||||
<div className="text-center">Date</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y">
|
||||
{loading ? (
|
||||
<div className="p-6 text-sm text-muted-foreground flex items-center gap-2">
|
||||
|
|
@ -279,34 +146,33 @@ export default function FileBrowserPanel() {
|
|||
<div className="p-6 text-sm text-muted-foreground">Empty folder.</div>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<div
|
||||
key={it.name + it.type}
|
||||
className="grid grid-cols-[minmax(320px,1fr),90px,96px,184px,120px] items-center px-3 py-2 text-sm hover:bg-muted/50"
|
||||
>
|
||||
<div key={it.name + it.type} className="grid items-center px-3 py-2 hover:bg-muted/50 fs-cell"
|
||||
style={{ gridTemplateColumns: "minmax(280px,1fr) 64px 84px 112px 118px" }}>
|
||||
{/* Name (truncate, tooltipped) */}
|
||||
<button
|
||||
className="text-left inline-flex items-center gap-2 hover:underline min-w-0"
|
||||
onClick={() => onOpen(it)}
|
||||
title={it.type === "dir" ? "Open folder" : "Preview"}
|
||||
title={it.name}
|
||||
>
|
||||
{it.type === "dir" ? (
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{it.type === "dir" ? <Folder className="h-4 w-4 shrink-0" /> : <FileText className="h-4 w-4 shrink-0" />}
|
||||
<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>
|
||||
|
||||
{/* Type */}
|
||||
<div className="text-center uppercase tracking-wide text-[11px] text-muted-foreground">{it.type}</div>
|
||||
|
||||
{/* Size */}
|
||||
<div className="text-right text-muted-foreground">{formatSize(it.size)}</div>
|
||||
|
||||
{/* Date (no time) */}
|
||||
<div className="text-center text-muted-foreground">{formatDate(it.mtimeMs)}</div>
|
||||
|
||||
{/* Actions (fixed width, never wraps) */}
|
||||
<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"
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[12px] hover:bg-muted"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
|
|
@ -319,40 +185,19 @@ export default function FileBrowserPanel() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GUTTER */}
|
||||
<div
|
||||
className="split-gutter"
|
||||
onMouseDown={onSplitDown as any}
|
||||
onTouchStart={onSplitDown as any}
|
||||
aria-label="Resize panels"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
|
||||
{/* RIGHT: preview */}
|
||||
<div className="min-w-[360px] flex-1" style={{ minHeight: 0 }}>
|
||||
<div className="p-3" style={{ height: "100%" }}>
|
||||
{/* RIGHT: preview (fixed min width; grows with viewport) */}
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-2 text-sm font-medium">Preview</div>
|
||||
<div className="border rounded overflow-hidden" style={{ height: 420 }}>
|
||||
<div className="border rounded overflow-hidden" style={{ height: 480 }}>
|
||||
{previewHref ? (
|
||||
<iframe
|
||||
key={previewHref}
|
||||
src={previewHref}
|
||||
className="w-full h-full"
|
||||
title="Preview"
|
||||
/>
|
||||
<iframe key={previewHref} src={previewHref} className="w-full h-full" title="Preview" />
|
||||
) : (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Select a file to preview.
|
||||
</div>
|
||||
<div className="p-3 text-sm text-muted-foreground">Select a file to preview.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue