makearmy-app/components/utilities/files/FileBrowserPanel.tsx

256 lines
8.8 KiB
TypeScript
Raw Normal View History

"use client";
2025-10-15 21:26:56 -04:00
import React, { useEffect, useMemo, useState, useCallback } from "react";
2025-10-15 21:45:00 -04:00
import {
Loader2,
Download,
Folder,
FileText,
RefreshCw,
ArrowUp,
Home,
} from "lucide-react";
2025-10-15 21:26:56 -04:00
type FileItem = {
name: string;
type: "dir" | "file";
size?: number;
mtimeMs?: number;
};
type ListResponse =
2025-10-15 21:35:31 -04:00
| { path: string; items: FileItem[] }
| { path: string; entries: FileItem[] }
2025-10-15 21:26:56 -04:00
| { items?: FileItem[]; entries?: FileItem[]; path?: string };
const BASE =
2025-10-15 21:35:31 -04:00
(process.env.NEXT_PUBLIC_FILE_API_BASE_URL || "").replace(/\/$/, "") || "";
2025-10-15 21:26:56 -04:00
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() {
2025-10-15 21:26:56 -04:00
const [path, setPath] = useState<string>("/");
const [items, setItems] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
2025-10-15 21:26:56 -04:00
const [previewHref, setPreviewHref] = useState<string | null>(null);
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}`;
}, []);
2025-10-15 21:26:56 -04:00
const fetchList = useCallback(async () => {
setLoading(true);
setError(null);
2025-10-15 21:26:56 -04:00
setPreviewHref(null);
try {
2025-10-15 21:26:56 -04:00
const res = await fetch(urlList, {
method: "GET",
2025-10-15 21:35:31 -04:00
credentials: "include",
2025-10-15 21:26:56 -04:00
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));
2025-10-15 21:26:56 -04:00
setItems([]);
} finally {
setLoading(false);
}
2025-10-15 21:26:56 -04:00
}, [urlList]);
2025-10-15 21:26:56 -04:00
useEffect(() => {
fetchList();
}, [fetchList]);
2025-10-15 21:26:56 -04:00
const onOpen = (it: FileItem) => {
if (it.type === "dir") {
setPath((p) => joinPath(p, it.name));
} else {
2025-10-15 21:26:56 -04:00
setPreviewHref(urlRaw(joinPath(path, it.name)));
}
2025-10-15 21:26:56 -04:00
};
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();
};
2025-10-15 21:26:56 -04:00
return (
<div className="space-y-3">
2025-10-15 21:45:00 -04:00
<style>{`
@media (min-width: 1024px) {
.fs-grid {
display: grid;
grid-template-columns: 1fr clamp(340px, 36vw, 480px);
gap: 1rem;
}
}
`}</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>
2025-10-15 21:45:00 -04:00
</div>
<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
2025-10-15 21:26:56 -04:00
</div>
2025-10-15 21:45:00 -04:00
) : 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"
>
2025-10-15 21:26:56 -04:00
<button
2025-10-15 21:45:00 -04:00
className="text-left inline-flex items-center gap-2 hover:underline min-w-0"
onClick={() => onOpen(it)}
title={it.type === "dir" ? "Open folder" : "Preview"}
2025-10-15 21:26:56 -04:00
>
2025-10-15 21:45:00 -04:00
{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>
2025-10-15 21:26:56 -04:00
</button>
2025-10-15 21:45:00 -04:00
<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"
/>
2025-10-15 21:26:56 -04:00
</div>
2025-10-15 21:45:00 -04:00
) : (
<div className="text-sm text-muted-foreground">
Select a file to preview.
2025-10-15 21:26:56 -04:00
</div>
2025-10-15 21:45:00 -04:00
)}
</div>
</div>
2025-10-15 21:26:56 -04:00
</div>
);
}