feat(frontend): slice 4 — migrate Dashboard + Applications to shadcn/Tailwind
Web UI rework. - Migrate pages/Dashboard.tsx off @mui (shortcut CRUD dialogs, machine switcher, BackupDashboardWidget mount; shortcuts reuse ConfirmDialog/ DialogFooter from slice 2) - Migrate pages/Applications.tsx shell off @mui (tabs + library counts); keeps <Media/> child intact (Media.tsx still MUI, deferred to slice 7 with its DataGrid) - Behavioral tests for both pages Gate: build + lint + test green.
This commit is contained in:
+245
-241
@@ -1,27 +1,25 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useActivity,
|
||||
useDashboardShortcuts,
|
||||
@@ -32,6 +30,7 @@ import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||
|
||||
@@ -71,6 +70,28 @@ function shortcutHref(shortcut: DashboardShortcut): string {
|
||||
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
helper,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
helper?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={htmlFor}>{label}</Label>
|
||||
{children}
|
||||
{helper ? (
|
||||
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutDialog({
|
||||
open,
|
||||
draft,
|
||||
@@ -85,129 +106,155 @@ function ShortcutDialog({
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Label"
|
||||
value={draft.label}
|
||||
onChange={(e) => onChange({ ...draft, label: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Icon"
|
||||
value={draft.icon}
|
||||
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||
helperText="Emoji or glyph"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
label="Type"
|
||||
value={draft.shortcut_type}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft.id ? "Edit shortcut" : "New shortcut"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||
<Field label="Label" htmlFor="shortcut-label">
|
||||
<Input
|
||||
id="shortcut-label"
|
||||
value={draft.label}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, label: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Field
|
||||
label="Icon"
|
||||
htmlFor="shortcut-icon"
|
||||
helper="Emoji or glyph"
|
||||
>
|
||||
<Input
|
||||
id="shortcut-icon"
|
||||
value={draft.icon}
|
||||
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||
<Field
|
||||
label="Type"
|
||||
htmlFor="shortcut-type"
|
||||
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
|
||||
>
|
||||
<Select
|
||||
value={draft.shortcut_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...draft,
|
||||
shortcut_type: e.target
|
||||
.value as DashboardShortcutInput["shortcut_type"],
|
||||
shortcut_type:
|
||||
value as DashboardShortcutInput["shortcut_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="website">Website</MenuItem>
|
||||
<MenuItem value="action">Saved action</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
<SelectTrigger id="shortcut-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="action">Saved action</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Website opens a URL. Saved actions jump to a task. Users
|
||||
deep-link.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.shortcut_type === "website" ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
<Field
|
||||
label="Website URL"
|
||||
value={draft.url}
|
||||
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||
helperText="https:// is added if missing."
|
||||
/>
|
||||
htmlFor="shortcut-url"
|
||||
helper="https:// is added if missing."
|
||||
>
|
||||
<Input
|
||||
id="shortcut-url"
|
||||
value={draft.url}
|
||||
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
) : draft.shortcut_type === "action" ? (
|
||||
<Grid container spacing={1.25}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Task ID"
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Field
|
||||
label="Task ID"
|
||||
htmlFor="shortcut-task"
|
||||
helper="Saved action ID."
|
||||
>
|
||||
<Input
|
||||
id="shortcut-task"
|
||||
value={draft.task_id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, task_id: e.target.value })
|
||||
}
|
||||
helperText="Saved action ID."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Machine ID"
|
||||
</Field>
|
||||
<Field
|
||||
label="Machine ID"
|
||||
htmlFor="shortcut-machine"
|
||||
helper="Optional machine target."
|
||||
>
|
||||
<Input
|
||||
id="shortcut-machine"
|
||||
value={draft.machine_id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, machine_id: e.target.value })
|
||||
}
|
||||
helperText="Optional machine target."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
<Field
|
||||
label="User ID"
|
||||
value={draft.user_id}
|
||||
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
|
||||
helperText="Jellyfin user ID."
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
htmlFor="shortcut-user"
|
||||
helper="Jellyfin user ID."
|
||||
>
|
||||
<Input
|
||||
id="shortcut-user"
|
||||
value={draft.user_id}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, enabled: e.target.checked })
|
||||
onChange({ ...draft, user_id: e.target.value })
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Notes" htmlFor="shortcut-notes">
|
||||
<Input
|
||||
id="shortcut-notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="shortcut-enabled"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...draft, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="shortcut-enabled">Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter
|
||||
onCancel={onClose}
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save shortcut"
|
||||
confirmBusyLabel="Save shortcut"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter
|
||||
onCancel={onClose}
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save shortcut"
|
||||
confirmBusyLabel="Save shortcut"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -237,72 +284,42 @@ function ShortcutCard({
|
||||
: shortcut.user_id || "No user configured";
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||
<CardContent sx={{ p: 1.25 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold">{shortcut.label}</div>
|
||||
<div className="truncate text-sm text-muted-foreground">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{shortcut.icon ? (
|
||||
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
|
||||
{shortcut.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{shortcut.notes ? (
|
||||
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
|
||||
) : null}
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!shortcut.enabled || !href}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 700 }} noWrap>
|
||||
{shortcut.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
{shortcut.icon ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
bgcolor: "action.hover",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{shortcut.icon}
|
||||
</Box>
|
||||
) : null}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={shortcut.shortcut_type}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{shortcut.notes ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{shortcut.notes}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={!shortcut.enabled || !href}
|
||||
onClick={onOpen}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -360,40 +377,41 @@ export function Dashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2.25}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Shortcuts"
|
||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||
action={
|
||||
<Button variant="outlined" onClick={openCreateShortcut}>
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{shortcuts.length ? (
|
||||
<Grid container spacing={1.25}>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
|
||||
<ShortcutCard
|
||||
shortcut={shortcut}
|
||||
onOpen={() => {
|
||||
const href = shortcutHref(shortcut);
|
||||
if (shortcut.shortcut_type === "website") {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
} else if (href) {
|
||||
navigate(href);
|
||||
}
|
||||
}}
|
||||
onEdit={() => openEditShortcut(shortcut)}
|
||||
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
||||
/>
|
||||
</Grid>
|
||||
<ShortcutCard
|
||||
key={shortcut.id}
|
||||
shortcut={shortcut}
|
||||
onOpen={() => {
|
||||
const href = shortcutHref(shortcut);
|
||||
if (shortcut.shortcut_type === "website") {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
} else if (href) {
|
||||
navigate(href);
|
||||
}
|
||||
}}
|
||||
onEdit={() => openEditShortcut(shortcut)}
|
||||
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
No shortcuts yet. Add a website now, then add action or user
|
||||
shortcuts later.
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No shortcuts yet. Add a website now, then add action or user
|
||||
shortcuts later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -403,25 +421,23 @@ export function Dashboard() {
|
||||
description="Live sessions and idle users from Jellyfin."
|
||||
action={
|
||||
jellyfinMachines.length > 1 ? (
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<Select
|
||||
value={selectedJellyfinId}
|
||||
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
|
||||
sx={{ fontSize: "0.8rem" }}
|
||||
>
|
||||
<Select
|
||||
value={selectedJellyfinId}
|
||||
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinMachines.map((m) => (
|
||||
<MenuItem key={m.id} value={m.id}>
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</MenuItem>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : jellyfinMachines.length === 1 ? (
|
||||
<Chip
|
||||
label={jellyfinMachines[0].name}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@@ -444,31 +460,19 @@ export function Dashboard() {
|
||||
onClose={() => setShortcutDialogOpen(false)}
|
||||
onSave={saveShortcutDraft}
|
||||
/>
|
||||
<Dialog
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteShortcutId)}
|
||||
onClose={() => setDeleteShortcutId(null)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Delete shortcut?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This cannot be undone. The shortcut will be removed from the
|
||||
dashboard.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogFooter
|
||||
onCancel={() => setDeleteShortcutId(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteShortcutId) {
|
||||
deleteShortcut.mutate(deleteShortcutId);
|
||||
}
|
||||
setDeleteShortcutId(null);
|
||||
}}
|
||||
confirmLabel="Delete"
|
||||
confirmColor="error"
|
||||
/>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
title="Delete shortcut?"
|
||||
message="This cannot be undone. The shortcut will be removed from the dashboard."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteShortcutId(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteShortcutId) {
|
||||
deleteShortcut.mutate(deleteShortcutId);
|
||||
}
|
||||
setDeleteShortcutId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user