refactor frontend and backend modules
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
|
||||
interface DisplayRow {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FfprobeStream {
|
||||
index?: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
codec_long_name?: string;
|
||||
profile?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bit_rate?: string | number;
|
||||
duration?: string | number;
|
||||
channels?: number;
|
||||
sample_rate?: string | number;
|
||||
channel_layout?: string;
|
||||
pix_fmt?: string;
|
||||
sample_aspect_ratio?: string;
|
||||
display_aspect_ratio?: string;
|
||||
field_order?: string;
|
||||
level?: number | string;
|
||||
color_range?: string;
|
||||
color_space?: string;
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeFormat {
|
||||
filename?: string;
|
||||
format_name?: string;
|
||||
format_long_name?: string;
|
||||
duration?: string | number;
|
||||
size?: string | number;
|
||||
bit_rate?: string | number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeData {
|
||||
format?: FfprobeFormat;
|
||||
streams?: FfprobeStream[];
|
||||
}
|
||||
|
||||
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 humanBytes(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const bytes = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(bytes)) return "-";
|
||||
return formatSize(bytes);
|
||||
}
|
||||
|
||||
function humanRate(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const rate = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(rate)) return "-";
|
||||
const units = ["bps", "Kbps", "Mbps", "Gbps"];
|
||||
let v = rate;
|
||||
let unitIdx = 0;
|
||||
while (v >= 1000 && unitIdx < units.length - 1) {
|
||||
v /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function humanDuration(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const seconds = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(seconds)) return "-";
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0)
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fieldLabel(_key: string, value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
const exts = [
|
||||
".mkv",
|
||||
".mp4",
|
||||
".avi",
|
||||
".m4v",
|
||||
".ts",
|
||||
".wmv",
|
||||
".mov",
|
||||
".flv",
|
||||
".webm",
|
||||
];
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||
|
||||
type FileBrowserState = {
|
||||
currentDir: string;
|
||||
pathInput: string;
|
||||
selectedPath: string | null;
|
||||
selectedJob: string;
|
||||
};
|
||||
|
||||
function defaultFileBrowserState(): FileBrowserState {
|
||||
return {
|
||||
currentDir: "/",
|
||||
pathInput: "/",
|
||||
selectedPath: null,
|
||||
selectedJob: "",
|
||||
};
|
||||
}
|
||||
|
||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
const format = data.format ?? {};
|
||||
const streams = data.streams ?? [];
|
||||
const videoStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "video",
|
||||
);
|
||||
const audioStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
const subtitleStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "subtitle",
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
|
||||
ffprobe details
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{path}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Container / format
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="body2">
|
||||
<b>Format:</b> {fieldLabel("format", format.format_name)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Long name:</b>{" "}
|
||||
{fieldLabel("format_long_name", format.format_long_name)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Duration:</b> {humanDuration(format.duration)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="body2">
|
||||
<b>Size:</b> {humanBytes(format.size)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Bitrate:</b> {humanRate(format.bit_rate)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Filename:</b> {fieldLabel("filename", format.filename)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Streams
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{videoStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Video streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{videoStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`video-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="primary"
|
||||
label={stream.codec_type ?? "video"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.codec_long_name && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_long_name}
|
||||
/>
|
||||
)}
|
||||
{stream.profile && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.profile}
|
||||
/>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanRate(stream.bit_rate)}
|
||||
/>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanDuration(stream.duration)}
|
||||
/>
|
||||
)}
|
||||
{stream.width && stream.height && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.width}×${stream.height}`}
|
||||
/>
|
||||
)}
|
||||
{stream.pix_fmt && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.pix_fmt}
|
||||
/>
|
||||
)}
|
||||
{stream.display_aspect_ratio && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`DAR ${stream.display_aspect_ratio}`}
|
||||
/>
|
||||
)}
|
||||
{stream.sample_aspect_ratio && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`SAR ${stream.sample_aspect_ratio}`}
|
||||
/>
|
||||
)}
|
||||
{stream.level !== undefined &&
|
||||
stream.level !== null && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`L${stream.level}`}
|
||||
/>
|
||||
)}
|
||||
{stream.field_order &&
|
||||
stream.field_order !== "unknown" && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.field_order}
|
||||
/>
|
||||
)}
|
||||
{(stream.color_range ||
|
||||
stream.color_space ||
|
||||
stream.color_transfer ||
|
||||
stream.color_primaries) && (
|
||||
<Chip
|
||||
size="small"
|
||||
color={
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("2084") ||
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("b67") ||
|
||||
(stream.color_space ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020") ||
|
||||
(stream.color_primaries ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020")
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
variant="outlined"
|
||||
label={[
|
||||
stream.color_range,
|
||||
stream.color_space,
|
||||
stream.color_transfer,
|
||||
stream.color_primaries,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ mt: 0.75 }}>
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title
|
||||
? `Title: ${stream.tags.title}.`
|
||||
: ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{audioStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Audio streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{audioStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`audio-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="secondary"
|
||||
label={stream.codec_type ?? "audio"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.channels && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.channels} ch`}
|
||||
/>
|
||||
)}
|
||||
{stream.sample_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.sample_rate} Hz`}
|
||||
/>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanRate(stream.bit_rate)}
|
||||
/>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanDuration(stream.duration)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ mt: 0.75 }}>
|
||||
{stream.codec_long_name
|
||||
? `${stream.codec_long_name}. `
|
||||
: ""}
|
||||
{stream.channel_layout
|
||||
? `Layout: ${stream.channel_layout}. `
|
||||
: ""}
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title
|
||||
? `Title: ${stream.tags.title}.`
|
||||
: ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{subtitleStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Subtitle streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`subtitle-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="info"
|
||||
label={stream.codec_type ?? "subtitle"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.tags?.language && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.tags.language}
|
||||
/>
|
||||
)}
|
||||
{stream.tags?.title && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.tags.title}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{streams.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No streams found.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Tags
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size="small"
|
||||
label={`${key}: ${value}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||
const initialRequestedPath = searchParams.get("path");
|
||||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||
FILE_BROWSER_STATE_KEY,
|
||||
() => {
|
||||
const requestedPath = initialRequestedPath ?? "/";
|
||||
const selectedPath =
|
||||
requestedPath !== "/" &&
|
||||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
||||
? requestedPath.replace(/\/+$/, "")
|
||||
: null;
|
||||
const currentDir = selectedPath
|
||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: requestedPath.replace(/\/+$/, "") || "/";
|
||||
return {
|
||||
...defaultFileBrowserState(),
|
||||
currentDir,
|
||||
pathInput: requestedPath || currentDir,
|
||||
selectedPath,
|
||||
};
|
||||
},
|
||||
);
|
||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||
setBrowserState((current) => ({ ...current, ...patch }));
|
||||
|
||||
const {
|
||||
data: listing,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
error: ffprobeError,
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob();
|
||||
|
||||
const navigate = (path: string) => {
|
||||
updateBrowserState({
|
||||
currentDir: path,
|
||||
pathInput: path,
|
||||
selectedPath: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") navigate(pathInput || "/");
|
||||
};
|
||||
|
||||
const rows: DisplayRow[] = [];
|
||||
if (currentDir !== "/") {
|
||||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||||
rows.push({
|
||||
id: `up-${parent}`,
|
||||
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() ?? "") : "";
|
||||
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
|
||||
rows.push({
|
||||
id: path,
|
||||
type: kind,
|
||||
name: entry.name,
|
||||
ext,
|
||||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||||
modified: formatTime(entry.mtime),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns: GridColDef<DisplayRow>[] = [
|
||||
{ field: "type", headerName: "Type", width: 90 },
|
||||
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
|
||||
{ field: "ext", headerName: "Ext", width: 90 },
|
||||
{ field: "size", headerName: "Size", width: 120 },
|
||||
{ field: "modified", headerName: "Modified", width: 190 },
|
||||
];
|
||||
|
||||
const rowSelectionModel: GridRowSelectionModel = selectedPath
|
||||
? { type: "include", ids: new Set([selectedPath]) }
|
||||
: { type: "include", ids: new Set() };
|
||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h5">File Browser</Typography>
|
||||
|
||||
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Remote path"
|
||||
value={pathInput}
|
||||
onChange={(e) => updateBrowserState({ pathInput: e.target.value })}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
<Button
|
||||
fullWidth={isMobile}
|
||||
variant="outlined"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth={isMobile}
|
||||
variant="outlined"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Current: {currentDir}{" "}
|
||||
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 420,
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
rowSelectionModel={rowSelectionModel}
|
||||
columnVisibilityModel={
|
||||
isMobile ? { ext: false, modified: false } : undefined
|
||||
}
|
||||
hideFooter
|
||||
sx={{
|
||||
"& .MuiDataGrid-columnHeaders": {
|
||||
fontWeight: 700,
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
onRowClick={(params) => {
|
||||
const row = params.row as DisplayRow;
|
||||
if (row.type === "dir" || row.type === "up") navigate(row.path);
|
||||
else
|
||||
updateBrowserState({
|
||||
selectedPath: row.path,
|
||||
currentDir,
|
||||
pathInput: row.path,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{selectedPath && isVideoFile(selectedPath) && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
{ffprobeError ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{String(ffprobeError)}
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<Typography color="text.secondary">
|
||||
Loading ffprobe data...
|
||||
</Typography>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<Typography color="text.secondary">
|
||||
No ffprobe data available.
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedPath && templates && templates.length > 0 && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Jobs
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Job template</InputLabel>
|
||||
<Select
|
||||
label="Job template"
|
||||
value={selectedJob}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ selectedJob: e.target.value })
|
||||
}
|
||||
>
|
||||
{templates.map((tpl) => (
|
||||
<MenuItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ alignSelf: "center" }}
|
||||
>
|
||||
{selectedTemplate.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{runJob.data && (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
overflow: "auto",
|
||||
maxHeight: 260,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Exit: {runJob.data.exit_status}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user