Files
manage/frontend/src/pages/FileBrowser.tsx
T
alex 3c432473e5 Add FastAPI backend and React frontend subprojects
Backend:
- FastAPI app with 17 REST endpoints covering dashboard, monitoring,
  media index, file browser, and jobs
- Reuses existing clients/domain/services unchanged
- pydantic-settings config, dependency injection, CORS setup
- Auto-generated OpenAPI docs at /docs

Frontend:
- Vite + React + TypeScript SPA
- @tanstack/react-query for data fetching with polling
- ag-grid-react for media table and file browser
- recharts for monitoring charts
- Tailwind CSS styling
- 4 pages: Dashboard, Monitoring, Media, File Browser
- Typed API client matching all backend endpoints

Also:
- docs/MIGRATION_PLAN.md with full architecture plan
- Updated .gitignore for both subprojects
- Streamlit app preserved for now (can coexist)
2026-04-30 21:40:18 +02:00

223 lines
5.6 KiB
TypeScript

import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
interface DisplayRow {
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(epoch: number): string {
if (!epoch) return "";
return new Date(epoch * 1000).toLocaleString();
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
type: "up",
name: "..",
ext: "",
size: "-",
modified: "",
path: parent,
});
}
if (listing) {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
rows.push({
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Refresh
</button>
</div>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
)}
</div>
);
}