Add missing frontend and backend files
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { MonitoringMachine, SavedTaskInput } from "../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useMonitoringSettings,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_machine_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_machine_id === b.default_machine_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
machines,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedMachine = machines.find(
|
||||
(machine) => machine.id === task.default_machine_id,
|
||||
);
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={task.task_type} />
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={task.enabled ? "enabled" : "disabled"}
|
||||
/>
|
||||
{selectedMachine && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`default: ${selectedMachine.name}`}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
label="Type"
|
||||
value={task.task_type}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: e.target.value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="shell">Shell</MenuItem>
|
||||
<MenuItem value="python">Python</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
|
||||
<InputLabel>Default machine</InputLabel>
|
||||
<Select
|
||||
label="Default machine"
|
||||
value={task.default_machine_id}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_machine_id: String(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{machines.map((machine) => (
|
||||
<MenuItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Notes"
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={9}
|
||||
size="small"
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
machines,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
machines: MonitoringMachine[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
||||
</DialogContent>
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="outlined" color="error" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runMachineId, setRunMachineId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
|
||||
const createNew = () => {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunMachineId(machines[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_machine_id: saved.default_machine_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
const editingTask = selectedTask;
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save reusable server tasks and switch between them with tabs.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label={`${tasks.length} saved`} variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{saveTask.error && (
|
||||
<Alert severity="error">{String(saveTask.error)}</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert severity="error">{String(deleteTask.error)}</Alert>
|
||||
)}
|
||||
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
|
||||
footer={
|
||||
<Button fullWidth variant="contained" onClick={createNew}>
|
||||
+ New action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
variant="scrollable"
|
||||
sx={{ borderRight: 1, borderColor: "divider" }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<Box
|
||||
key={task.id}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
"&:hover .rail-edit": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value={task.id}
|
||||
label={task.name}
|
||||
sx={{
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
width: 1,
|
||||
pr: 5,
|
||||
}}
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => {
|
||||
const initial = {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
<HoverEditButton
|
||||
onClick={() => {
|
||||
const initial = {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_machine_id: task.default_machine_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{editingTask ? (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{editingTask.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Open the editor popup to modify this action.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap" }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const initial = {
|
||||
id: editingTask.id,
|
||||
name: editingTask.name,
|
||||
task_type: editingTask.task_type,
|
||||
content: editingTask.content,
|
||||
enabled: editingTask.enabled,
|
||||
default_machine_id: editingTask.default_machine_id,
|
||||
notes: editingTask.notes,
|
||||
};
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={runTask.isPending || !runMachineId}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
machineId: runMachineId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel>Run on machine</InputLabel>
|
||||
<Select
|
||||
label="Run on machine"
|
||||
value={runMachineId}
|
||||
onChange={(e) =>
|
||||
setRunMachineId(String(e.target.value))
|
||||
}
|
||||
>
|
||||
{machines.map((machine) => (
|
||||
<MenuItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Recent runs
|
||||
</Typography>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<Stack spacing={1.25}>
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id} variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={run.status}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{run.machine_name} ·{" "}
|
||||
{new Date(
|
||||
run.created_at * 1000,
|
||||
).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{run.stdout_tail && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
stdout
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{run.stdout_tail}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
stderr
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{run.stderr_tail}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert severity="error">{run.error}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert severity="info">No runs yet.</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No action selected
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select a saved action from the list on the left to view
|
||||
its details, run it, or open the editor popup. Use the
|
||||
button at the bottom to add a new action.
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap" }}
|
||||
>
|
||||
<Button variant="contained" onClick={createNew}>
|
||||
+ New action
|
||||
</Button>
|
||||
{tasks[0] && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setTab(tasks[0].id)}
|
||||
>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
What this panel shows
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Saved actions stay on the left rail, while details, run
|
||||
controls, and recent history appear here.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
machines={machines}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Media } from "./Media";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
function JellyfinLibraryStats() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
machines.filter(
|
||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const selectedMachineId =
|
||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
||||
const { data: counts } = useCounts(selectedMachineId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedMachineId || undefined);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Library stats"
|
||||
description="Compact Jellyfin summary for the selected machine."
|
||||
action={
|
||||
<Chip
|
||||
label={selectedMachineId ? "Selected machine" : "Default machine"}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
{counts ? (
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Movies
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.movies.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Series
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.series.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Episodes
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
||||
>
|
||||
{counts.episodes.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : null}
|
||||
|
||||
{libraries?.length ? (
|
||||
<Grid container spacing={1}>
|
||||
{libraries.map((library) => (
|
||||
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ py: 1.1, px: 1.5 }}>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700 }}
|
||||
noWrap
|
||||
>
|
||||
{library.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total {library.total.toLocaleString()} · Movies{" "}
|
||||
{library.movies.toLocaleString()} · Series{" "}
|
||||
{library.series.toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Applications() {
|
||||
const [tab, setTab] = useState("jellyfin");
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
||||
Applications
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Browse application-specific tools from a compact tabbed workspace.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
|
||||
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
|
||||
]}
|
||||
>
|
||||
{tab === "jellyfin" ? (
|
||||
<Stack spacing={2}>
|
||||
<JellyfinLibraryStats />
|
||||
<Media />
|
||||
</Stack>
|
||||
) : (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Alert severity="info">
|
||||
Nextcloud support will be added in a future update.
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user