300 lines
8.7 KiB
TypeScript
300 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
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 };
|
|
|
|
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]}`;
|
|
}
|
|
|
|
function formatDate(ms?: number) {
|
|
if (!ms) return "—";
|
|
const d = new Date(ms);
|
|
return d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "numeric",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export default function FileBrowserPanel() {
|
|
const [path, setPath] = useState<string>("/");
|
|
const [items, setItems] = useState<FileItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [previewHref, setPreviewHref] = useState<string | null>(null);
|
|
|
|
const urlList = useMemo(() => {
|
|
const p = encodeURIComponent(path || "/");
|
|
return `/api/files/list?path=${p}`;
|
|
}, [path]);
|
|
|
|
const urlDownload = useCallback((p: string) => {
|
|
const qp = encodeURIComponent(p || "/");
|
|
return `/api/files/download?path=${qp}`;
|
|
}, []);
|
|
|
|
const urlRaw = useCallback((p: string) => {
|
|
const qp = encodeURIComponent(p || "/");
|
|
return `/api/files/raw?path=${qp}`;
|
|
}, []);
|
|
|
|
const fetchList = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setPreviewHref(null);
|
|
|
|
try {
|
|
const res = await fetch(urlList, {
|
|
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) =>
|
|
a.type !== b.type
|
|
? a.type === "dir"
|
|
? -1
|
|
: 1
|
|
: a.name.localeCompare(b.name)
|
|
);
|
|
|
|
setItems(arr);
|
|
} catch (e: any) {
|
|
setError(e?.message || String(e));
|
|
setItems([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [urlList]);
|
|
|
|
useEffect(() => {
|
|
fetchList();
|
|
}, [fetchList]);
|
|
|
|
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();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<style>{`
|
|
.fs-cell { font-size: 13.5px; line-height: 1.3 }
|
|
.fs-tight { letter-spacing: .01em }
|
|
.fs-table {
|
|
--col-name-min: 260px;
|
|
--col-type: 56px;
|
|
--col-size: 72px;
|
|
--col-date: 96px;
|
|
--col-actions: 44px;
|
|
display: grid;
|
|
grid-template-columns:
|
|
minmax(var(--col-name-min), 1fr)
|
|
var(--col-type)
|
|
var(--col-size)
|
|
var(--col-date)
|
|
var(--col-actions);
|
|
}
|
|
.fs-nowrap { white-space: nowrap }
|
|
.fs-iconbtn {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
}
|
|
`}</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>
|
|
|
|
<div className="select-all text-xs text-muted-foreground">{path || "/"}</div>
|
|
|
|
<div className="grid grid-cols-1 items-start gap-3 xl:grid-cols-[minmax(560px,1fr)_minmax(420px,42vw)]">
|
|
<div className="overflow-x-auto rounded-md border">
|
|
<div className="fs-table bg-muted px-2 py-2 fs-cell fs-tight">
|
|
<div className="px-1">Name</div>
|
|
<div className="text-center">Type</div>
|
|
<div className="text-right">Size</div>
|
|
<div className="text-center">Date</div>
|
|
<div className="fs-nowrap pr-1 text-right">Get</div>
|
|
</div>
|
|
|
|
<div className="divide-y">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading…
|
|
</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="fs-table fs-cell items-center px-2 py-2 hover:bg-muted/50"
|
|
>
|
|
<button
|
|
className="min-w-0 px-1 text-left inline-flex items-center gap-2 hover:underline"
|
|
onClick={() => onOpen(it)}
|
|
title={it.name}
|
|
>
|
|
{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="text-center text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
{it.type}
|
|
</div>
|
|
|
|
<div className="text-right text-muted-foreground">
|
|
{formatSize(it.size)}
|
|
</div>
|
|
|
|
<div className="text-center text-muted-foreground">
|
|
{formatDate(it.mtimeMs)}
|
|
</div>
|
|
|
|
<div className="pr-1 text-right">
|
|
{it.type === "file" && (
|
|
<button
|
|
onClick={() => onDownload(it)}
|
|
className="fs-iconbtn hover:bg-muted"
|
|
title="Download"
|
|
aria-label={`Download ${it.name}`}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border p-3">
|
|
<div className="mb-2 text-sm font-medium">Preview</div>
|
|
<div className="overflow-hidden rounded border" style={{ height: 520 }}>
|
|
{previewHref ? (
|
|
<iframe
|
|
key={previewHref}
|
|
src={previewHref}
|
|
className="h-full w-full"
|
|
title="Preview"
|
|
/>
|
|
) : (
|
|
<div className="p-3 text-sm text-muted-foreground">
|
|
Select a file to preview.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|