file browser small UI improvements

This commit is contained in:
makearmy 2025-10-15 21:54:38 -04:00
parent edcde5ba55
commit a6971f4535

View file

@ -1,6 +1,13 @@
"use client";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
MutableRefObject,
} from "react";
import {
Loader2,
Download,
@ -49,6 +56,8 @@ function formatSize(bytes?: number) {
return `${v.toFixed(u ? 1 : 0)} ${units[u]}`;
}
const LS_KEY = "fs.split.left"; // px
export default function FileBrowserPanel() {
const [path, setPath] = useState<string>("/");
const [items, setItems] = useState<FileItem[]>([]);
@ -56,6 +65,38 @@ 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}`;
@ -104,6 +145,7 @@ export default function FileBrowserPanel() {
fetchList();
}, [fetchList]);
// ───────────────── Actions
const onOpen = (it: FileItem) => {
if (it.type === "dir") {
setPath((p) => joinPath(p, it.name));
@ -123,133 +165,194 @@ export default function FileBrowserPanel() {
a.remove();
};
return (
<div className="space-y-3">
<style>{`
@media (min-width: 1024px) {
.fs-grid {
display: grid;
grid-template-columns: 1fr clamp(340px, 36vw, 480px);
gap: 1rem;
// ───────────────── 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 (
<div className="space-y-3">
{/* Small styles */}
<style>{`
.split-gutter {
width: 8px;
cursor: col-resize;
user-select: none;
touch-action: none;
background: transparent;
}
}
`}</style>
.split-gutter:hover { background: rgba(255,255,255,0.06); }
@media (max-width: 1023px) {
.split-wrap { display:block !important; }
.split-gutter { display:none; }
}
`}</style>
<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"
>
<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"
>
<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"
>
<ArrowUp className="h-4 w-4" /> Up
</button>
</div>
</div>
{/* 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"
>
<Home className="h-4 w-4" /> root
</button>
<div className="text-xs text-muted-foreground select-all">{path || "/"}</div>
<div className="fs-grid">
{/* TABLE */}
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[560px]">
{/* compact column plan: name grows; others stay tidy */}
<div className="grid grid-cols-[minmax(260px,1fr),88px,92px,156px,auto] 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 ? (
<div className="p-6 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
<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"
>
<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"
>
<ArrowUp className="h-4 w-4" /> Up
</button>
</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>
) : (
items.map((it) => (
<div
key={it.name + it.type}
className="grid grid-cols-[minmax(260px,1fr),88px,92px,156px,auto] items-center px-3 py-2 text-sm hover:bg-muted/50"
>
<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"}
>
{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-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">
<div>Name</div>
<div>Type</div>
<div>Size</div>
<div>Modified</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y">
{loading ? (
<div className="p-6 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</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"
) : 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>
) : (
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"
>
<Download className="h-3.5 w-3.5" />
Download
<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"}
>
{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>
</div>
))
)}
</div>
</div>
</div>
</div>
<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>
{/* PREVIEW */}
<div className="rounded-md border p-3">
<div className="mb-2 text-sm font-medium">Preview</div>
{previewHref ? (
<div className="border rounded overflow-hidden">
<iframe
key={previewHref}
src={previewHref}
className="w-full h-[420px]"
title="Preview"
{/* 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%" }}>
<div className="mb-2 text-sm font-medium">Preview</div>
<div className="border rounded overflow-hidden" style={{ height: 420 }}>
{previewHref ? (
<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>
) : (
<div className="text-sm text-muted-foreground">
Select a file to preview.
</div>
)}
</div>
</div>
</div>
);
</div>
</div>
</div>
);
}