Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
300
app/components/utilities/files/FileBrowserPanel.tsx
Normal file
300
app/components/utilities/files/FileBrowserPanel.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"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-[minmax(560px,1fr)_minmax(420px,42vw)] items-start gap-3">
|
||||
<div className="overflow-hidden 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>
|
||||
);
|
||||
}
|
||||
56
app/components/utilities/files/FilePreview.tsx
Normal file
56
app/components/utilities/files/FilePreview.tsx
Normal 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>;
|
||||
}
|
||||
112
app/components/utilities/files/FilesTable.tsx
Normal file
112
app/components/utilities/files/FilesTable.tsx
Normal 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">Get</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>
|
||||
);
|
||||
}
|
||||
81
app/components/utilities/files/api.ts
Normal file
81
app/components/utilities/files/api.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue