completely refactored utilities for direct rendering, killed iframes

This commit is contained in:
makearmy 2025-10-12 22:24:23 -04:00
parent 12dd2c6c06
commit f08a7456ee
37 changed files with 1824 additions and 1350 deletions

View file

@ -0,0 +1,166 @@
// components/utilities/files/FileBrowserPanel.tsx
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronRight, Home, Loader2 } from "lucide-react";
import FilesTable from "./FilesTable";
import FilePreview from "./FilePreview";
import { FsEntry, list, parentDir, download, SortDir, SortKey } from "./api";
export default function FileBrowserPanel() {
const [cwd, setCwd] = useState<string>("/");
const [entries, setEntries] = useState<FsEntry[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<FsEntry | null>(null);
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const refresh = useCallback(async (path: string) => {
setLoading(true);
setError(null);
try {
const res = await list(path);
setCwd(res.cwd || path || "/");
setEntries(res.entries || []);
// Clear selection if it no longer exists
if (selected && !res.entries.find(e => e.path === selected.path)) setSelected(null);
} catch (e: any) {
setError(e?.message || String(e));
} finally {
setLoading(false);
}
}, [selected]);
useEffect(() => { refresh(cwd); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
const crumbs = useMemo(() => {
const norm = cwd.replace(/\\/g, "/");
const segs = norm.split("/").filter(Boolean);
const out: { label: string; path: string }[] = [{ label: "root", path: "/" }];
let acc = "";
for (const s of segs) {
acc += "/" + s;
out.push({ label: s, path: acc || "/" });
}
return out;
}, [cwd]);
function openEntry(e: FsEntry) {
if (e.isDir) {
setSelected(null);
refresh(e.path);
} else {
setSelected(e);
}
}
function onSort(k: SortKey) {
if (k === sortKey) {
setSortDir(d => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(k);
setSortDir("asc");
}
}
async function goUp() {
const p = parentDir(cwd);
setSelected(null);
await refresh(p);
}
return (
<div className="space-y-4">
{/* Top bar */}
<div className="flex flex-wrap items-center gap-2">
{/* Breadcrumbs */}
<nav className="flex items-center gap-1 text-sm">
{crumbs.map((c, i) => (
<span key={c.path} className="inline-flex items-center">
{i === 0 ? (
<button
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 hover:bg-muted"
onClick={() => { setSelected(null); refresh("/"); }}
title="Go to root"
>
<Home className="w-3.5 h-3.5" />
root
</button>
) : (
<button
className="rounded-md border px-2 py-0.5 hover:bg-muted"
onClick={() => { setSelected(null); refresh(c.path); }}
title={c.path}
>
{c.label}
</button>
)}
{i < crumbs.length - 1 && <ChevronRight className="w-3.5 h-3.5 mx-1 opacity-60" />}
</span>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => refresh(cwd)}
className="rounded-md border px-2 py-1 text-sm hover:bg-muted inline-flex items-center gap-2"
title="Refresh"
disabled={loading}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Refresh
</button>
<button
onClick={goUp}
className="rounded-md border px-2 py-1 text-sm hover:bg-muted"
title="Up one level"
disabled={cwd === "/"}
>
Up
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="rounded-md border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
{error}
</div>
)}
{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
{loading ? (
<div className="rounded-md border p-6 text-sm text-zinc-400 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Loading
</div>
) : (
<FilesTable
entries={entries}
sortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
onOpen={openEntry}
onDownload={(e) => download(e.path)}
/>
)}
</div>
<div className="lg:col-span-1">
<div className="rounded-md border p-3">
<div className="text-sm text-zinc-400 mb-2">Preview</div>
{selected ? (
<FilePreview path={selected.path} mime={selected.mime} name={selected.name} />
) : (
<div className="text-sm text-zinc-500">Select a file to preview.</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
// components/utilities/files/FilePreview.tsx
"use client";
import { useEffect, useState } from "react";
import { rawUrl, isPreviewableImage, isPreviewableText, isPreviewablePdf } from "./api";
export default function FilePreview({ path, mime, name }: { path: string; mime?: string | null; name?: string }) {
const [text, setText] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function load() {
setText("");
if (isPreviewableText(mime, name)) {
try {
const res = await fetch(rawUrl(path), { cache: "no-store" });
const t = await res.text();
if (!cancelled) setText(t.slice(0, 100_000)); // safety cap
} catch {
if (!cancelled) setText("Unable to load text preview.");
}
}
}
load();
return () => { cancelled = true; };
}, [path, mime, name]);
if (isPreviewableImage(mime, name)) {
return (
<div className="rounded-md border overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={rawUrl(path)} alt={name || ""} className="w-full max-h-[60vh] object-contain bg-muted" />
</div>
);
}
if (isPreviewablePdf(mime, name)) {
return (
<iframe
src={rawUrl(path)}
className="w-full h-[60vh] rounded-md border"
title={name || "PDF preview"}
/>
);
}
if (isPreviewableText(mime, name)) {
return (
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto max-h-[60vh] text-xs whitespace-pre-wrap">
{text || "Loading…"}
</pre>
);
}
return <div className="text-sm text-zinc-400">No preview available.</div>;
}

View file

@ -0,0 +1,112 @@
// components/utilities/files/FilesTable.tsx
"use client";
import { useMemo } from "react";
import { ArrowDownAZ, ArrowUpAZ, Download, Folder, FileText } from "lucide-react";
import { FsEntry, SortKey, SortDir, nicelyFormatBytes } from "./api";
export default function FilesTable({
entries,
sortKey,
sortDir,
onSort,
onOpen,
onDownload,
}: {
entries: FsEntry[];
sortKey: SortKey;
sortDir: SortDir;
onSort: (k: SortKey) => void;
onOpen: (entry: FsEntry) => void;
onDownload: (entry: FsEntry) => void;
}) {
const sorted = useMemo(() => {
const arr = [...entries];
const dir = sortDir === "asc" ? 1 : -1;
arr.sort((a, b) => {
// folders first
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
switch (sortKey) {
case "name": return a.name.localeCompare(b.name) * dir;
case "size": return ((a.size ?? -1) - (b.size ?? -1)) * dir;
case "modified": return (new Date(a.modified || 0).getTime() - new Date(b.modified || 0).getTime()) * dir;
case "type": {
const ax = ext(a.name), bx = ext(b.name);
return ax.localeCompare(bx) * dir;
}
}
});
return arr;
}, [entries, sortKey, sortDir]);
function ext(name: string) {
const m = /\.([^.]+)$/.exec(name || "");
return m ? m[1].toLowerCase() : "";
}
function SortBtn({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k;
return (
<button className="inline-flex items-center gap-1 text-left" onClick={() => onSort(k)}>
{label}
{active ? (
sortDir === "asc" ? <ArrowUpAZ className="w-3.5 h-3.5 opacity-60" /> : <ArrowDownAZ className="w-3.5 h-3.5 opacity-60" />
) : null}
</button>
);
}
return (
<div className="overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead className="text-left text-zinc-400 bg-muted/40">
<tr>
<th className="px-3 py-2 w-[40%]"><SortBtn k="name" label="Name" /></th>
<th className="px-3 py-2 w-[15%]"><SortBtn k="type" label="Type" /></th>
<th className="px-3 py-2 w-[15%]"><SortBtn k="size" label="Size" /></th>
<th className="px-3 py-2 w-[30%]"><SortBtn k="modified" label="Modified" /></th>
<th className="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((e) => (
<tr key={e.path} className="border-t hover:bg-muted/30">
<td
className="px-3 py-2 cursor-pointer"
onDoubleClick={() => onOpen(e)}
title="Double-click to open"
>
<span className="inline-flex items-center gap-2">
{e.isDir ? <Folder className="w-4 h-4 opacity-70" /> : <FileText className="w-4 h-4 opacity-70" />}
<span className="truncate">{e.name}</span>
</span>
</td>
<td className="px-3 py-2">{e.isDir ? "Folder" : (e.mime || ext(e.name).toUpperCase() || "File")}</td>
<td className="px-3 py-2">{e.isDir ? "—" : nicelyFormatBytes(e.size)}</td>
<td className="px-3 py-2">{e.modified ? new Date(e.modified).toLocaleString() : "—"}</td>
<td className="px-3 py-2">
{!e.isDir && (
<div className="flex justify-end">
<button
className="rounded-md border px-2 py-1 text-xs hover:bg-muted inline-flex items-center gap-1"
onClick={() => onDownload(e)}
>
<Download className="w-3.5 h-3.5" />
Download
</button>
</div>
)}
</td>
</tr>
))}
{sorted.length === 0 && (
<tr>
<td className="px-3 py-6 text-sm text-zinc-500" colSpan={5}>Empty folder.</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,81 @@
// components/utilities/files/api.ts
export type FsEntry = {
name: string;
path: string; // absolute or from root, e.g. "/public" or "/public/readme.txt"
isDir: boolean;
size?: number | null; // bytes
modified?: string | null; // ISO date string
mime?: string | null; // server-provided mime, optional
};
export type ListResponse = {
cwd: string; // normalized path we listed
entries: FsEntry[]; // unsorted list
};
export type SortKey = "name" | "size" | "modified" | "type";
export type SortDir = "asc" | "desc";
export async function list(path: string): Promise<ListResponse> {
const u = new URL("/api/files/list", location.origin);
if (path) u.searchParams.set("path", path);
const res = await fetch(u.toString(), { credentials: "include", cache: "no-store" });
if (!res.ok) throw new Error(`list ${path}: ${res.status}`);
return res.json();
}
export function rawUrl(path: string): string {
const u = new URL("/api/files/raw", location.origin);
u.searchParams.set("path", path);
return u.toString();
}
export async function download(path: string): Promise<void> {
// try direct browser download via a hidden <a download>
const u = new URL("/api/files/download", location.origin);
u.searchParams.set("path", path);
const a = document.createElement("a");
a.href = u.toString();
a.rel = "noopener";
a.download = ""; // hint to save-as
document.body.appendChild(a);
a.click();
a.remove();
}
export function parentDir(p: string): string {
if (!p || p === "/") return "/";
const segs = p.replace(/\\/g, "/").split("/").filter(Boolean);
segs.pop();
return "/" + segs.join("/");
}
export function nicelyFormatBytes(n?: number | null): string {
if (!Number.isFinite(n as number) || (n as number) < 0) return "—";
const b = n as number;
if (b < 1024) return `${b} B`;
const units = ["KB","MB","GB","TB"];
let v = b / 1024, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
}
export function extFromName(name: string): string {
const m = /\.([^.]+)$/.exec(name || "");
return m ? m[1].toLowerCase() : "";
}
export function isPreviewableImage(mime?: string | null, name?: string): boolean {
const ext = extFromName(name || "");
return /^image\//.test(mime || "") || ["png","jpg","jpeg","gif","webp","bmp","svg"].includes(ext);
}
export function isPreviewableText(mime?: string | null, name?: string): boolean {
const ext = extFromName(name || "");
return /^text\//.test(mime || "") || ["txt","csv","md","json","log"].includes(ext);
}
export function isPreviewablePdf(mime?: string | null, name?: string): boolean {
const ext = extFromName(name || "");
return (mime || "").includes("pdf") || ext === "pdf";
}