feat(frontend): slice 2 — migrate 11 shared components to shadcn/Tailwind
Web UI rework. Shared-components slice (drift prevention): - Migrate SectionCard, SelectionRailCard, TabbedCard, MetricCard, DiskSpaceCard, HoverEditButton, DialogFooter, ConfirmDialog, LibraryOverview, NowPlaying, SessionActivityPanel off @mui - HoverEditButton: MUI IconButton + EditOutlined -> Button + lucide Pencil - Status mapping uses the success Badge variant (chart-2) for healthy - Exported APIs preserved so consuming pages still compile (no page edits) - 11 behavioral Vitest component tests added Gate: build + lint + test green.
This commit is contained in:
@@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Stack,
|
} from "@/components/ui/dialog";
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { DialogFooter } from "./DialogFooter";
|
import { DialogFooter } from "./DialogFooter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable confirmation dialog built on the shadcn Dialog family and the
|
||||||
|
* shared `DialogFooter`. Same exported props as the MUI version; Esc / overlay
|
||||||
|
* click routes to `onCancel` via `onOpenChange`.
|
||||||
|
*/
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -25,23 +30,26 @@ export function ConfirmDialog({
|
|||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
|
<Dialog
|
||||||
<DialogTitle>{title}</DialogTitle>
|
open={open}
|
||||||
<DialogContent>
|
onOpenChange={(next) => {
|
||||||
<Stack spacing={1}>
|
if (!next) onCancel();
|
||||||
<Typography variant="body2" color="text.secondary">
|
}}
|
||||||
{message}
|
>
|
||||||
</Typography>
|
<DialogContent showCloseButton={false}>
|
||||||
</Stack>
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{message}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmLabel={confirmLabel}
|
||||||
|
confirmColor="error"
|
||||||
|
confirmBusyLabel={confirmLabel}
|
||||||
|
confirmDisabled={busy}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter
|
|
||||||
onCancel={onCancel}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
confirmLabel={confirmLabel}
|
|
||||||
confirmColor="error"
|
|
||||||
confirmBusyLabel={confirmLabel}
|
|
||||||
confirmDisabled={busy}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Button, DialogActions } from "@mui/material";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface DialogFooterProps {
|
interface DialogFooterProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -14,6 +14,28 @@ interface DialogFooterProps {
|
|||||||
secondaryAction?: ReactNode;
|
secondaryAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the legacy MUI color/variant props onto a shadcn Button variant so
|
||||||
|
* the exported API stays unchanged for consuming pages (ConfirmDialog here,
|
||||||
|
* plus Dashboard/Settings/Actions in later slices).
|
||||||
|
*/
|
||||||
|
function resolveConfirmVariant(
|
||||||
|
color: DialogFooterProps["confirmColor"],
|
||||||
|
variant: DialogFooterProps["confirmVariant"],
|
||||||
|
): "default" | "outline" | "ghost" | "destructive" {
|
||||||
|
if (color === "error") return "destructive";
|
||||||
|
if (variant === "outlined") return "outline";
|
||||||
|
if (variant === "text") return "ghost";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog action row: cancel + optional secondary action + confirm.
|
||||||
|
*
|
||||||
|
* Renders a horizontal Button row (`flex flex-row items-center gap-2`).
|
||||||
|
* Preserves cancel/confirm/secondary-action props and the busy/disabled label
|
||||||
|
* contract (renders `confirmBusyLabel` when provided, else `confirmLabel`).
|
||||||
|
*/
|
||||||
export function DialogFooter({
|
export function DialogFooter({
|
||||||
onCancel,
|
onCancel,
|
||||||
cancelLabel = "Cancel",
|
cancelLabel = "Cancel",
|
||||||
@@ -27,20 +49,23 @@ export function DialogFooter({
|
|||||||
secondaryAction,
|
secondaryAction,
|
||||||
}: DialogFooterProps) {
|
}: DialogFooterProps) {
|
||||||
return (
|
return (
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
{cancelLabel}
|
||||||
{secondaryAction}
|
</Button>
|
||||||
<Button
|
{secondaryAction ? (
|
||||||
variant={confirmVariant}
|
<div className="flex flex-row items-center gap-2">
|
||||||
color={confirmColor}
|
{secondaryAction}
|
||||||
disabled={confirmDisabled}
|
</div>
|
||||||
startIcon={confirmStartIcon}
|
) : null}
|
||||||
onClick={onConfirm}
|
<Button
|
||||||
>
|
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||||
{confirmBusyLabel ?? confirmLabel}
|
disabled={confirmDisabled}
|
||||||
</Button>
|
onClick={onConfirm}
|
||||||
</Box>
|
>
|
||||||
</DialogActions>
|
{confirmStartIcon}
|
||||||
|
{confirmBusyLabel ?? confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Box,
|
import { Progress } from "@/components/ui/progress";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Grid,
|
|
||||||
LinearProgress,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
used: number;
|
used: number;
|
||||||
@@ -27,127 +20,58 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress-bar class (full static strings so Tailwind's scanner emits them).
|
||||||
|
* `chart-2`=success, `chart-3`=warning, `destructive`=error, per design §2.3.
|
||||||
|
*/
|
||||||
|
function progressBarClass(pct: number): string {
|
||||||
|
if (pct < 70) {
|
||||||
|
return "h-3 [&_[data-slot=progress-indicator]]:bg-chart-2";
|
||||||
|
}
|
||||||
|
if (pct < 90) {
|
||||||
|
return "h-3 [&_[data-slot=progress-indicator]]:bg-chart-3";
|
||||||
|
}
|
||||||
|
return "h-3 [&_[data-slot=progress-indicator]]:bg-destructive";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard card that summarizes the configured media disk.
|
* Dashboard card that summarizes the configured media disk.
|
||||||
*
|
*
|
||||||
* It intentionally keeps the progress bar inside the card so the capacity
|
* Keeps the progress bar inside the card so the capacity signal, raw byte
|
||||||
* signal, raw byte values, and free-space breakdown stay visually grouped.
|
* values, and free-space breakdown stay visually grouped. The used / free /
|
||||||
|
* total / percent breakdown is preserved verbatim from the MUI version.
|
||||||
*/
|
*/
|
||||||
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
|
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
|
||||||
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
|
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
|
||||||
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
|
const cells = [
|
||||||
|
{ label: "Used", value: formatBytes(used) },
|
||||||
|
{ label: "Free", value: formatBytes(available) },
|
||||||
|
{ label: "Total", value: formatBytes(size) },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
<Card className="h-full">
|
||||||
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-1">
|
||||||
<Box>
|
<span className="text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
<Typography
|
Disk space
|
||||||
variant="caption"
|
</span>
|
||||||
color="text.secondary"
|
<span className="text-lg font-semibold">{usedPct} used</span>
|
||||||
sx={{ textTransform: "uppercase" }}
|
</div>
|
||||||
|
<Progress value={pct} className={progressBarClass(pct)} />
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{cells.map((cell) => (
|
||||||
|
<div
|
||||||
|
key={cell.label}
|
||||||
|
className="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-muted/50 p-3 text-center"
|
||||||
>
|
>
|
||||||
Disk space
|
<span className="text-xs text-muted-foreground">
|
||||||
</Typography>
|
{cell.label}
|
||||||
<Typography
|
</span>
|
||||||
variant="h5"
|
<span className="text-sm font-semibold">{cell.value}</span>
|
||||||
sx={{
|
</div>
|
||||||
fontWeight: 700,
|
))}
|
||||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{usedPct} used
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={pct}
|
|
||||||
color={barColor}
|
|
||||||
sx={{
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
"& .MuiLinearProgress-bar": {
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Used
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(used)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Free
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(available)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Total
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(size)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import { Pencil } from "lucide-react";
|
||||||
import { IconButton } from "@mui/material";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover-to-reveal edit affordance.
|
||||||
|
*
|
||||||
|
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
||||||
|
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||||
|
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||||
|
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
||||||
|
* + lucide `Pencil`. Same exported props/display name.
|
||||||
|
*/
|
||||||
export function HoverEditButton({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
}: HoverEditButtonProps) {
|
}: HoverEditButtonProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<Button
|
||||||
className="rail-edit"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
size="small"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
onClick();
|
||||||
}}
|
}}
|
||||||
sx={{
|
|
||||||
opacity: 0,
|
|
||||||
transition: "opacity 120ms ease",
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="inherit" />
|
<Pencil />
|
||||||
</IconButton>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,57 @@
|
|||||||
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import type { LibraryCount } from "../types";
|
import type { LibraryCount } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraries: LibraryCount[];
|
libraries: LibraryCount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column overview of movie and TV libraries on a responsive CSS grid
|
||||||
|
* (`grid grid-cols-1 md:grid-cols-2 gap-4`). Same exported props as the MUI
|
||||||
|
* version; the per-library counts render verbatim.
|
||||||
|
*/
|
||||||
export function LibraryOverview({ libraries }: Props) {
|
export function LibraryOverview({ libraries }: Props) {
|
||||||
const movieLibs = libraries.filter((l) => l.type === "movies");
|
const movieLibs = libraries.filter((l) => l.type === "movies");
|
||||||
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<div className="flex flex-col gap-4">
|
||||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||||
Movie libraries
|
Movie libraries
|
||||||
</Typography>
|
</h4>
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-4">
|
||||||
{movieLibs.map((lib) => (
|
{movieLibs.map((lib) => (
|
||||||
<Card key={lib.library} variant="outlined">
|
<Card key={lib.library}>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-1">
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<span className="text-base font-semibold">{lib.library}</span>
|
||||||
{lib.library}
|
<span className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
||||||
{lib.movies.toLocaleString()}
|
{lib.movies.toLocaleString()}
|
||||||
</Typography>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<div className="flex flex-col gap-4">
|
||||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||||
TV libraries
|
TV libraries
|
||||||
</Typography>
|
</h4>
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-4">
|
||||||
{tvLibs.map((lib) => (
|
{tvLibs.map((lib) => (
|
||||||
<Card key={lib.library} variant="outlined">
|
<Card key={lib.library}>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-1">
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<span className="text-base font-semibold">{lib.library}</span>
|
||||||
{lib.library}
|
<span className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Total: {lib.total.toLocaleString()} | Series:{" "}
|
Total: {lib.total.toLocaleString()} | Series:{" "}
|
||||||
{lib.series.toLocaleString()}
|
{lib.series.toLocaleString()}
|
||||||
</Typography>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -6,44 +6,24 @@ interface Props {
|
|||||||
subtext?: string;
|
subtext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact metric tile: label / value / optional subtext on the comfortable
|
||||||
|
* density ramp (label `text-sm`, value `text-lg font-semibold`, subtext
|
||||||
|
* `text-xs text-muted-foreground`). Same exported props as the MUI version.
|
||||||
|
*/
|
||||||
export function MetricCard({ label, value, subtext }: Props) {
|
export function MetricCard({ label, value, subtext }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
<Card className="h-full">
|
||||||
<CardContent
|
<CardContent className="flex h-full flex-col gap-1.5">
|
||||||
sx={{
|
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
|
||||||
p: { xs: 1.5, sm: 2 },
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 0.5,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</span>
|
||||||
<Typography
|
<span className="text-lg font-semibold leading-tight">{value}</span>
|
||||||
variant="h5"
|
{subtext ? (
|
||||||
sx={{
|
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{subtext && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
|
|
||||||
>
|
|
||||||
{subtext}
|
{subtext}
|
||||||
</Typography>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface SectionCardProps {
|
interface SectionCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,6 +8,13 @@ interface SectionCardProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Titled section surface built on the shadcn Card family.
|
||||||
|
*
|
||||||
|
* Comfortable density: `gap-4` between the header row and the body. Exports
|
||||||
|
* the same props/display name as the prior MUI implementation so every
|
||||||
|
* consuming page compiles unchanged.
|
||||||
|
*/
|
||||||
export function SectionCard({
|
export function SectionCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -15,32 +22,18 @@ export function SectionCard({
|
|||||||
children,
|
children,
|
||||||
}: SectionCardProps) {
|
}: SectionCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined">
|
<Card className="gap-4">
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Stack spacing={1.25}>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Box
|
<div className="min-w-0">
|
||||||
sx={{
|
<h3 className="text-base font-semibold">{title}</h3>
|
||||||
display: "flex",
|
{description ? (
|
||||||
alignItems: "center",
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
justifyContent: "space-between",
|
) : null}
|
||||||
gap: 1,
|
</div>
|
||||||
flexWrap: "wrap",
|
{action}
|
||||||
}}
|
</div>
|
||||||
>
|
{children}
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{description ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Typography } from "@mui/material";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
interface SelectionRailCardProps {
|
interface SelectionRailCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
contentSx?: object;
|
contentSx?: object;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
bodySx?: object;
|
bodySx?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection-rail surface: titled header, scrollable body, optional footer.
|
||||||
|
*
|
||||||
|
* Preserves the exported props (`minHeight`, `footer`, and the legacy `*Sx`
|
||||||
|
* no-op passthroughs) so consuming pages (Actions, Settings) compile
|
||||||
|
* unchanged. The scrollable body and footer contract are preserved.
|
||||||
|
*/
|
||||||
export function SelectionRailCard({
|
export function SelectionRailCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
minHeight = 420,
|
minHeight = 420,
|
||||||
contentSx,
|
|
||||||
bodySx,
|
|
||||||
}: SelectionRailCardProps) {
|
}: SelectionRailCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
|
<Card className="h-fit self-start py-0" style={{ minHeight }}>
|
||||||
<CardContent
|
<div className="flex flex-col" style={{ minHeight }}>
|
||||||
sx={{
|
<div className="border-b bg-muted/50 px-4 py-3">
|
||||||
p: 0,
|
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
minHeight,
|
|
||||||
...contentSx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1.5,
|
|
||||||
py: 1.25,
|
|
||||||
borderBottom: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</div>
|
||||||
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
{footer ? (
|
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
|
||||||
<Box
|
</div>
|
||||||
sx={{
|
|
||||||
p: 1,
|
|
||||||
borderTop: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.paper",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Paper,
|
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
Typography,
|
} from "@/components/ui/table";
|
||||||
} from "@mui/material";
|
|
||||||
import type { NowPlayingSession } from "../types";
|
import type { NowPlayingSession } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +17,23 @@ interface Props {
|
|||||||
onSelectSession?: (session: NowPlayingSession) => void;
|
onSelectSession?: (session: NowPlayingSession) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionStateVariant = "success" | "warning" | "secondary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a session state onto a Badge variant per design §2.3.
|
||||||
|
*
|
||||||
|
* `playing` (active/healthy) → `success` (chart-2), `paused` → `warning`
|
||||||
|
* (chart-3), anything else (idle/unknown) → `secondary` (neutral accent).
|
||||||
|
*/
|
||||||
|
function sessionStateVariant(state: string): SessionStateVariant {
|
||||||
|
const normalized = String(state || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (normalized === "playing") return "success";
|
||||||
|
if (normalized === "paused") return "warning";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
function formatStateLabel(state: string): string {
|
function formatStateLabel(state: string): string {
|
||||||
const normalized = String(state || "")
|
const normalized = String(state || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
|
|||||||
const userFallback = selectedUserLabel || "Unknown user";
|
const userFallback = selectedUserLabel || "Unknown user";
|
||||||
|
|
||||||
if (!sessions.length) {
|
if (!sessions.length) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{emptyMessage}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer
|
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
|
||||||
component={Paper}
|
<Table aria-label="Session activity details" className="min-w-[880px]">
|
||||||
variant="outlined"
|
<TableHeader>
|
||||||
sx={{
|
<TableRow className="bg-card hover:bg-card">
|
||||||
maxHeight: 280,
|
<TableHead className="min-w-[160px]">User</TableHead>
|
||||||
borderColor: "divider",
|
<TableHead className="w-[82px]">State</TableHead>
|
||||||
borderRadius: 1,
|
<TableHead className="min-w-[140px]">Title / Type</TableHead>
|
||||||
overflowX: "auto",
|
<TableHead className="min-w-[140px]">Device</TableHead>
|
||||||
}}
|
<TableHead className="w-[118px]">Transcoding</TableHead>
|
||||||
>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
stickyHeader
|
|
||||||
aria-label="Session activity details"
|
|
||||||
sx={{ minWidth: 880 }}
|
|
||||||
>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 160,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
User
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
|
|
||||||
>
|
|
||||||
State
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Title / Type
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Device
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
width: 118,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Transcoding
|
|
||||||
</TableCell>
|
|
||||||
{onSelectSession ? (
|
{onSelectSession ? (
|
||||||
<TableCell
|
<TableHead className="w-[150px]">Action</TableHead>
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
width: 150,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={onSelectSession ? 6 : 5}
|
colSpan={onSelectSession ? 6 : 5}
|
||||||
sx={{ py: 0.75, bgcolor: "background.paper" }}
|
className="bg-card py-3"
|
||||||
>
|
>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<span className="text-xs text-muted-foreground">
|
||||||
{buildStatusSummary(sessions)}
|
{buildStatusSummary(sessions)}
|
||||||
</Typography>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const state = String(session.state || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const sessionLabel = formatStateLabel(session.state);
|
const sessionLabel = formatStateLabel(session.state);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
hover
|
className={onSelectSession ? "cursor-pointer" : undefined}
|
||||||
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
|
|
||||||
onClick={
|
onClick={
|
||||||
onSelectSession ? () => onSelectSession(session) : undefined
|
onSelectSession ? () => onSelectSession(session) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
|
<TableCell className="min-w-[160px]">
|
||||||
<Typography
|
<div
|
||||||
variant="body2"
|
className="truncate text-sm"
|
||||||
noWrap
|
|
||||||
title={session.user || userFallback}
|
title={session.user || userFallback}
|
||||||
>
|
>
|
||||||
{session.user || userFallback}
|
{session.user || userFallback}
|
||||||
</Typography>
|
</div>
|
||||||
<Typography
|
<div
|
||||||
variant="caption"
|
className="truncate text-xs text-muted-foreground"
|
||||||
color="text.secondary"
|
|
||||||
noWrap
|
|
||||||
title={session.session_id}
|
title={session.session_id}
|
||||||
>
|
>
|
||||||
{session.session_id}
|
{session.session_id}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Chip
|
<Badge variant={sessionStateVariant(session.state)}>
|
||||||
size="small"
|
{sessionLabel}
|
||||||
label={sessionLabel}
|
</Badge>
|
||||||
color={
|
|
||||||
state === "playing"
|
|
||||||
? "primary"
|
|
||||||
: state === "paused"
|
|
||||||
? "warning"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
state === "playing" || state === "paused"
|
|
||||||
? "filled"
|
|
||||||
: "outlined"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell className="min-w-[140px]">
|
||||||
<Typography
|
<div className="truncate text-sm" title={session.title || ""}>
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
title={session.title || ""}
|
|
||||||
>
|
|
||||||
{session.title || "(idle)"}
|
{session.title || "(idle)"}
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{session.type || "—"}
|
{session.type || "—"}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell className="min-w-[140px]">
|
||||||
<Typography variant="body2" noWrap>
|
<div className="truncate text-sm">
|
||||||
{session.device || "Unknown device"}
|
{session.device || "Unknown device"}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Typography variant="body2" noWrap>
|
<span className="text-sm">
|
||||||
{session.transcoding === "yes"
|
{session.transcoding === "yes"
|
||||||
? session.transcoding_type
|
? session.transcoding_type
|
||||||
? `yes (${session.transcoding_type})`
|
? `yes (${session.transcoding_type})`
|
||||||
: "yes"
|
: "yes"
|
||||||
: "no"}
|
: "no"}
|
||||||
</Typography>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{onSelectSession ? (
|
{onSelectSession ? (
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
variant="outline"
|
||||||
variant="outlined"
|
size="sm"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Tabs } from "@mui/material";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList } from "@/components/ui/tabs";
|
||||||
|
|
||||||
interface TabbedCardProps {
|
interface TabbedCardProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
tabs: ReactElement[];
|
tabs: ReactElement[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
contentSx?: object;
|
contentSx?: object;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
tabsSx?: object;
|
tabsSx?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card surface with a line-style tab bar on top and a content area below.
|
||||||
|
*
|
||||||
|
* `value`/`onChange` stay string-typed (controlled) and the `tabs` prop stays
|
||||||
|
* `ReactElement[]`, so consuming pages compile unchanged. The page owns the
|
||||||
|
* rendered content from `children` keyed off `value`, exactly as before.
|
||||||
|
*/
|
||||||
export function TabbedCard({
|
export function TabbedCard({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
tabs,
|
tabs,
|
||||||
children,
|
children,
|
||||||
contentSx,
|
|
||||||
tabsSx,
|
|
||||||
}: TabbedCardProps) {
|
}: TabbedCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined">
|
<Card className="gap-0 py-0">
|
||||||
<CardContent sx={{ p: 0 }}>
|
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
|
||||||
<Tabs
|
<div className="border-b px-2">
|
||||||
value={value}
|
<TabsList variant="line">{tabs}</TabsList>
|
||||||
onChange={(_, next) => onChange(String(next))}
|
</div>
|
||||||
variant="scrollable"
|
<div className="p-4">{children}</div>
|
||||||
scrollButtons="auto"
|
</Tabs>
|
||||||
allowScrollButtonsMobile
|
|
||||||
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
|
|
||||||
>
|
|
||||||
{tabs}
|
|
||||||
</Tabs>
|
|
||||||
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
|
|
||||||
|
describe("ConfirmDialog", () => {
|
||||||
|
it("renders the title and message and wires confirm/cancel", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Delete machine?"
|
||||||
|
message="This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open={false}
|
||||||
|
title="Hidden"
|
||||||
|
message="nope"
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { DialogFooter } from "../DialogFooter";
|
||||||
|
|
||||||
|
describe("DialogFooter", () => {
|
||||||
|
it("renders cancel/confirm labels and wires both callbacks", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onCancel}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmLabel="Save"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the busy label and maps confirmColor=error to destructive", () => {
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
confirmBusyLabel="Deleting…"
|
||||||
|
confirmColor="error"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const confirm = screen.getByRole("button", { name: "Deleting…" });
|
||||||
|
expect(confirm).toBeInTheDocument();
|
||||||
|
expect(confirm.getAttribute("data-variant")).toBe("destructive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the secondary action when provided", () => {
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
confirmLabel="OK"
|
||||||
|
secondaryAction={<button type="button">Test SSH</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Test SSH" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { DiskSpaceCard } from "../DiskSpaceCard";
|
||||||
|
|
||||||
|
describe("DiskSpaceCard", () => {
|
||||||
|
it("preserves the used / free / total breakdown and percent headline", () => {
|
||||||
|
render(
|
||||||
|
<DiskSpaceCard
|
||||||
|
used={500000000000}
|
||||||
|
available={500000000000}
|
||||||
|
size={1000000000000}
|
||||||
|
usedPct="50"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/50 used/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Used")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Free")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||||
|
// Disk space label
|
||||||
|
expect(screen.getByText(/disk space/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { HoverEditButton } from "../HoverEditButton";
|
||||||
|
|
||||||
|
describe("HoverEditButton", () => {
|
||||||
|
it("fires onClick and exposes the default aria-label", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<HoverEditButton onClick={onClick} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom label", () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { LibraryOverview } from "../LibraryOverview";
|
||||||
|
import type { LibraryCount } from "../../types";
|
||||||
|
|
||||||
|
const libraries: LibraryCount[] = [
|
||||||
|
{
|
||||||
|
library: "Films",
|
||||||
|
type: "movies",
|
||||||
|
movies: 100,
|
||||||
|
series: 0,
|
||||||
|
episodes: 0,
|
||||||
|
total: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
library: "Shows",
|
||||||
|
type: "tvshows",
|
||||||
|
movies: 0,
|
||||||
|
series: 12,
|
||||||
|
episodes: 240,
|
||||||
|
total: 252,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("LibraryOverview", () => {
|
||||||
|
it("renders movie and TV library cards with their counts", () => {
|
||||||
|
render(<LibraryOverview libraries={libraries} />);
|
||||||
|
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TV libraries")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Films")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Shows")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MetricCard } from "../MetricCard";
|
||||||
|
|
||||||
|
describe("MetricCard", () => {
|
||||||
|
it("renders the label, value, and subtext on the comfortable ramp", () => {
|
||||||
|
render(
|
||||||
|
<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits subtext when not provided", () => {
|
||||||
|
render(<MetricCard label="Series" value="42" />);
|
||||||
|
expect(screen.getByText("Series")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("42")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { NowPlaying } from "../NowPlaying";
|
||||||
|
|
||||||
|
describe("NowPlaying", () => {
|
||||||
|
it("renders the dashboard empty-state message contract when there are no sessions", () => {
|
||||||
|
render(<NowPlaying sessions={[]} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No recent user activity sessions right now."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { SectionCard } from "../SectionCard";
|
||||||
|
|
||||||
|
describe("SectionCard", () => {
|
||||||
|
it("renders title, description, action, and children", () => {
|
||||||
|
render(
|
||||||
|
<SectionCard
|
||||||
|
title="Shortcuts"
|
||||||
|
description="Quick links"
|
||||||
|
action={<button type="button">Add</button>}
|
||||||
|
>
|
||||||
|
<p>Body content</p>
|
||||||
|
</SectionCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Quick links")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Body content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a description or action", () => {
|
||||||
|
render(<SectionCard title="Only title">children</SectionCard>);
|
||||||
|
expect(screen.getByText("Only title")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("children")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { SelectionRailCard } from "../SelectionRailCard";
|
||||||
|
|
||||||
|
describe("SelectionRailCard", () => {
|
||||||
|
it("renders the title, body, and footer and honors minHeight", () => {
|
||||||
|
render(
|
||||||
|
<SelectionRailCard
|
||||||
|
title="Saved tasks"
|
||||||
|
description="Pick one"
|
||||||
|
minHeight={200}
|
||||||
|
footer={<button type="button">New task</button>}
|
||||||
|
>
|
||||||
|
<div>Task A</div>
|
||||||
|
</SelectionRailCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Task A")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "New task" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// minHeight is applied to the Card via inline style.
|
||||||
|
const card = screen
|
||||||
|
.getByText("Saved tasks")
|
||||||
|
.closest("[data-slot='card']") as HTMLElement | null;
|
||||||
|
expect(card?.style.minHeight).toBe("200px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a footer", () => {
|
||||||
|
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
|
||||||
|
expect(screen.getByText("No footer")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SessionActivityPanel } from "../SessionActivityPanel";
|
||||||
|
import type { NowPlayingSession } from "../../types";
|
||||||
|
|
||||||
|
function session(
|
||||||
|
overrides: Partial<NowPlayingSession> = {},
|
||||||
|
): NowPlayingSession {
|
||||||
|
return {
|
||||||
|
user: "alice",
|
||||||
|
title: "Movie",
|
||||||
|
type: "Movie",
|
||||||
|
state: "playing",
|
||||||
|
transcoding: "no",
|
||||||
|
transcoding_type: "",
|
||||||
|
device: "Web",
|
||||||
|
session_id: "s1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionActivityPanel", () => {
|
||||||
|
it("maps a playing (healthy) session to the success Badge variant", () => {
|
||||||
|
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
|
||||||
|
const badge = screen.getByText("Playing");
|
||||||
|
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps paused → warning and idle → secondary", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
|
||||||
|
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
|
||||||
|
"secondary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty-state message when there are no sessions", () => {
|
||||||
|
render(
|
||||||
|
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelectSession on row click and on the action button", async () => {
|
||||||
|
const onSelectSession = vi.fn();
|
||||||
|
render(
|
||||||
|
<SessionActivityPanel
|
||||||
|
sessions={[session({ state: "playing" })]}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText("alice"));
|
||||||
|
expect(onSelectSession).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Open in Users" }),
|
||||||
|
);
|
||||||
|
expect(onSelectSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { TabbedCard } from "../TabbedCard";
|
||||||
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
describe("TabbedCard", () => {
|
||||||
|
it("renders the provided tab triggers and reports selection changes", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<TabbedCard
|
||||||
|
value="jellyfin"
|
||||||
|
onChange={onChange}
|
||||||
|
tabs={[
|
||||||
|
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||||
|
Jellyfin
|
||||||
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||||
|
Nextcloud
|
||||||
|
</TabsTrigger>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p>Body</p>
|
||||||
|
</TabbedCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Nextcloud"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("nextcloud");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -168,3 +168,165 @@ unblocked now that all primitives, TanStack Table, the Vitest harness, the `succ
|
|||||||
- `npm audit` reports 8 vulnerabilities in the dependency tree (pre-existing across the
|
- `npm audit` reports 8 vulnerabilities in the dependency tree (pre-existing across the
|
||||||
MUI/emotion/react stack); not introduced by this slice and out of scope.
|
MUI/emotion/react stack); not introduced by this slice and out of scope.
|
||||||
- `DatabaseBackup` from lucide-react is available — Slice 3 nav should use it directly.
|
- `DatabaseBackup` from lucide-react is available — Slice 3 nav should use it directly.
|
||||||
|
|
||||||
|
## Slice 2 — Shared components (lock the building-block language) — COMPLETE
|
||||||
|
|
||||||
|
All 12 Slice-2 task lines in `tasks.md` are now `- [x]`. The 11 shared components
|
||||||
|
are MUI-free and the exported APIs are preserved so every consuming page still
|
||||||
|
compiles unchanged (no `frontend/src/pages/*` file was edited this slice).
|
||||||
|
|
||||||
|
### Status context consumed
|
||||||
|
|
||||||
|
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
|
||||||
|
domain specs missing/partial; legacy flat `spec.md` present without domain
|
||||||
|
specs). This is a **planning-completeness** gap, not a safety/`actionContext`
|
||||||
|
blocker. `actionContext`: `mode: repo-local`, `allowedEditRoots: ["/home/user/Manage_01"]`,
|
||||||
|
`warnings: []` — safe.
|
||||||
|
- This run executed the explicitly delegated **Slice 2 (Shared components)**
|
||||||
|
scope per the parent acceptance contract. Slice-2 work is fully specified in
|
||||||
|
`tasks.md` + `design.md` (§1 mapping table, §2 typography ramp, §2.3 success
|
||||||
|
Badge variant) and does not depend on the missing domain specs. The
|
||||||
|
`instructions.apply` line says "Implement only unchecked tasks from the tasks
|
||||||
|
artifact." → proceeded under the parent's explicit slice delegation.
|
||||||
|
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`
|
||||||
|
(Slice 2: 0 → 12 `[x]`). Cumulative change task progress: 13 → **25/71** complete.
|
||||||
|
|
||||||
|
### What was migrated (10 files rewritten; 1 already-clean)
|
||||||
|
|
||||||
|
- `SectionCard.tsx` → shadcn `Card`/`CardContent`; `gap-4` comfortable density;
|
||||||
|
section title `text-base font-semibold`, description `text-sm text-muted-foreground`.
|
||||||
|
- `SelectionRailCard.tsx` → `Card` with titled header / scrollable body / footer.
|
||||||
|
Preserved `minHeight` (inline style), `footer`, scrollable body. Legacy
|
||||||
|
`contentSx`/`bodySx` MUI-sx props **retained in the interface as no-ops** so
|
||||||
|
Actions/Settings compile unchanged.
|
||||||
|
- `TabbedCard.tsx` → shadcn `Tabs` (`TabsList variant="line"`) on a `Card`.
|
||||||
|
`value`/`onChange` stay string-typed (controlled); `tabs` stays `ReactElement[]`;
|
||||||
|
legacy `contentSx`/`tabsSx` retained as no-op props.
|
||||||
|
- `MetricCard.tsx` → `Card`/`CardContent` on the design ramp: label `text-sm`,
|
||||||
|
value `text-lg font-semibold`, subtext `text-xs text-muted-foreground`.
|
||||||
|
- `DiskSpaceCard.tsx` → `Card` + CSS grid (`grid-cols-1 sm:grid-cols-3 gap-4`)
|
||||||
|
- shadcn `Progress`; used/free/total/percent breakdown preserved; the progress
|
||||||
|
color cue (chart-2 success / chart-3 warning / destructive) is emitted via full
|
||||||
|
static `[&_[data-slot=progress-indicator]]:bg-*` class strings (verified present
|
||||||
|
in the built CSS).
|
||||||
|
- `HoverEditButton.tsx` → `Button variant="ghost" size="icon-sm"` + lucide `Pencil`.
|
||||||
|
Kept the `rail-edit` class + `opacity-0`/`transition-opacity` base so the existing
|
||||||
|
hover-reveal rules in Actions/Settings (`&:hover .rail-edit { opacity: 1 }`)
|
||||||
|
still target it until those pages migrate (slices 5). MUI `IconButton`+
|
||||||
|
`EditOutlined` removed.
|
||||||
|
- `DialogFooter.tsx` → horizontal `Button` row (`flex flex-row items-center
|
||||||
|
gap-2`). **All legacy props preserved**: `confirmColor` (`error`→`destructive`
|
||||||
|
variant) and `confirmVariant` (`outlined`→`outline`, `text`→`ghost`, else
|
||||||
|
`default`) are mapped internally onto shadcn variants; `confirmBusyLabel`,
|
||||||
|
`confirmDisabled`, `confirmStartIcon`, `secondaryAction`, `cancelLabel` all honored.
|
||||||
|
- `ConfirmDialog.tsx` → shadcn `Dialog` family (`DialogContent`/`DialogHeader`/
|
||||||
|
`DialogTitle`/`DialogDescription`) reusing the migrated `DialogFooter`. Esc /
|
||||||
|
overlay click routes to `onCancel` via `onOpenChange`. Same exported props.
|
||||||
|
- `LibraryOverview.tsx` → `Card`/`CardContent` on a responsive CSS grid
|
||||||
|
(`grid grid-cols-1 md:grid-cols-2 gap-4`); movie/TV counts render verbatim.
|
||||||
|
- `SessionActivityPanel.tsx` → shadcn `Table` family on a bordered rounded
|
||||||
|
scrollable surface + `Badge` (status variant map per design §2.3: `playing`→
|
||||||
|
`success`/chart-2, `paused`→`warning`/chart-3, idle/other→`secondary`) +
|
||||||
|
`Button` for the action. Row-click + action-button callbacks, status summary
|
||||||
|
row, and transcoding formatting preserved.
|
||||||
|
- `NowPlaying.tsx` → **already MUI-free** (it only imports `../types` +
|
||||||
|
`./SessionActivityPanel`); left unchanged. Its empty-state message contract
|
||||||
|
("No recent user activity sessions right now.") is exercised by a new test.
|
||||||
|
|
||||||
|
### Component tests added (11 files, co-located under `src/components/__tests__/`)
|
||||||
|
|
||||||
|
- `MetricCard` (label/value/subtext + subtext-omitted), `DiskSpaceCard` (used/
|
||||||
|
free/total + percent headline), `HoverEditButton` (onClick fires, custom label),
|
||||||
|
`DialogFooter` (cancel/confirm callbacks, busy label, error→destructive,
|
||||||
|
secondary action), `ConfirmDialog` (title/message render + confirm/cancel,
|
||||||
|
closed renders nothing), `SectionCard` (title/description/action/children),
|
||||||
|
`SelectionRailCard` (title/body/footer + `minHeight` applied), `TabbedCard`
|
||||||
|
(renders triggers + reports selection change), `LibraryOverview` (movie/TV
|
||||||
|
cards + counts), `SessionActivityPanel` (status→Badge variant mapping
|
||||||
|
playing/paused/idle, empty-state, row-click + action-button callbacks),
|
||||||
|
`NowPlaying` (dashboard empty-state message contract).
|
||||||
|
|
||||||
|
### Files changed (tracked)
|
||||||
|
|
||||||
|
Modified (10 components):
|
||||||
|
|
||||||
|
- `frontend/src/components/{SectionCard,SelectionRailCard,TabbedCard,MetricCard,
|
||||||
|
DiskSpaceCard,HoverEditButton,DialogFooter,ConfirmDialog,LibraryOverview,
|
||||||
|
SessionActivityPanel}.tsx`
|
||||||
|
- `openspec/changes/web-ui-rework/tasks.md` (Slice 2 checkboxes 0 → 12 `[x]`)
|
||||||
|
|
||||||
|
Added (new, 11 test files):
|
||||||
|
|
||||||
|
- `frontend/src/components/__tests__/{MetricCard,DiskSpaceCard,HoverEditButton,
|
||||||
|
DialogFooter,ConfirmDialog,SectionCard,SelectionRailCard,TabbedCard,
|
||||||
|
LibraryOverview,SessionActivityPanel,NowPlaying}.test.tsx`
|
||||||
|
|
||||||
|
Untouched (no-unintended-edits respected): **no `frontend/src/pages/*` file
|
||||||
|
edited this slice** — `git status --porcelain frontend/src/pages` is empty.
|
||||||
|
`NowPlaying.tsx` is unchanged (already MUI-free). No `components/ui/*` primitive
|
||||||
|
was modified.
|
||||||
|
|
||||||
|
### Commands run (validation) — all green
|
||||||
|
|
||||||
|
- `grep -rlE '@mui/(material|icons-material)' <11 files>` → **ALL 11 MUI-FREE**.
|
||||||
|
- `cd frontend && npm run build` → **PASS** (`tsc -b` + `vite build`).
|
||||||
|
- `cd frontend && npm run lint` → **PASS** (0 errors; the only 2 items are the
|
||||||
|
pre-existing `react-hooks/exhaustive-deps` **warnings** in `UsersPage.impl.tsx`,
|
||||||
|
out of Slice-2 scope).
|
||||||
|
- `cd frontend && npm test` → **PASS** (Vitest: **12 files, 22 tests** passed;
|
||||||
|
11 new component tests + the slice-1 Badge smoke test).
|
||||||
|
- `cd frontend && npm run test:node` → **PASS** (legacy node:test: 4 tests, 0 fail).
|
||||||
|
- Verified Tailwind emitted the DiskSpaceCard `[data-slot=progress-indicator]`
|
||||||
|
chart-2/3/destructive utilities into the built CSS (arbitrary-variant classes
|
||||||
|
written as full static strings).
|
||||||
|
|
||||||
|
### API-preserving compromises (design constraints accepted to keep pages compiling)
|
||||||
|
|
||||||
|
1. **`SelectionRailCard` / `TabbedCard` legacy `*Sx` props.** Kept
|
||||||
|
`contentSx`/`bodySx`/`tabsSx` in the prop interfaces as documented no-ops
|
||||||
|
(MUI `sx` objects have no Tailwind equivalent). They are intentionally **not**
|
||||||
|
destructured into locals (avoids `no-unused-vars`) and are ignored at render.
|
||||||
|
Consumers (Actions, Settings) pass them today and compile unchanged.
|
||||||
|
2. **`DialogFooter` color/variant mapping.** `confirmColor`/`confirmVariant` are
|
||||||
|
MUI-only concepts; they are retained on the API and mapped to shadcn Button
|
||||||
|
variants (`error`→`destructive`, `outlined`→`outline`, `text`→`ghost`). All
|
||||||
|
current consumers pass at most `confirmColor="error"` and `secondaryAction`,
|
||||||
|
which map cleanly. No consumer passes `confirmVariant` explicitly today.
|
||||||
|
3. **`HoverEditButton` hover reveal.** Implemented with a Tailwind `opacity-0`/
|
||||||
|
`transition-opacity` base (not inline style) so the not-yet-migrated pages'
|
||||||
|
MUI-sx `&:hover .rail-edit { opacity: 1 }` rules still override it on hover
|
||||||
|
(higher specificity) during the interim. The `rail-edit` class is preserved.
|
||||||
|
4. **`SessionActivityPanel` status→Badge mapping.** `playing` (active/healthy)
|
||||||
|
→ `success` per the task's "healthy=`success`" + design §2.3; `paused`→`warning`;
|
||||||
|
idle/unknown→`secondary`. This is the documented cue map (no app semantics
|
||||||
|
changed — the prior MUI Chip used primary/warning/default coloring).
|
||||||
|
|
||||||
|
### Carry-over risk / top risk for slice 3 (and interim)
|
||||||
|
|
||||||
|
- **`TabbedCard` interim tab-bar rendering.** Until Applications (slice 4) and
|
||||||
|
Settings/Actions (slice 5) migrate, those pages still pass MUI `<Tab>` elements
|
||||||
|
as the `tabs` prop, which now render inside a shadcn `<TabsList>`. MUI `<Tab>`
|
||||||
|
outside an MUI `<Tabs>` does **not** throw (renders with a console warning) and
|
||||||
|
the page content still swaps via the page's external `value` state, but the
|
||||||
|
tab "active" highlight is cosmetic-only until those pages migrate. **Build /
|
||||||
|
lint / test are unaffected.** This is an expected interim state of the chained
|
||||||
|
model and resolves fully once slices 4–5 land. (Pages are intentionally not
|
||||||
|
edited in slice 2.)
|
||||||
|
- **Slice-2 PR budget.** Review churn ≈ 775 lines on the 10 rewritten components
|
||||||
|
(303 ins / 472 del; mostly MUI teardown) + 335 lines of new tests ≈ ~1,110
|
||||||
|
changed lines, **over the ≤400 budget**. The forecast authorizes sub-split 2a
|
||||||
|
(cards/buttons/dialogs) → 2b (tables/panels) on overrun. The parent delegated
|
||||||
|
the whole slice as one unit and owns the commit/PR, so this run delivered it
|
||||||
|
in one piece; the parent may split 2a/2b at PR time or take a size exception
|
||||||
|
(mirroring the slice-1 precedent). Functionally the slice is complete and all
|
||||||
|
gates are green.
|
||||||
|
- Overall change `applyState` remains **blocked** on missing domain specs (legacy
|
||||||
|
flat `spec.md`); does not block Slice 2 (done) but should be resolved before
|
||||||
|
`sdd-verify`/archive.
|
||||||
|
|
||||||
|
### Remaining tasks (Slices 3–8, 46 unchecked)
|
||||||
|
|
||||||
|
Slice 2 is complete (25/71 tasks). Next in dependency order: **Slice 3 — Backups
|
||||||
|
cluster + nav/IA** (uses slice-2 `Table`/`Badge`/`Tabs`/cards and lands the
|
||||||
|
`/backups` nav item + `/applications`→`/media` redirect; `DatabaseBackup` icon
|
||||||
|
confirmed available). See `tasks.md` Slices 3–8 for the verbatim unchecked list.
|
||||||
|
|||||||
@@ -125,18 +125,18 @@ Each slice section restates this gate as its final task.
|
|||||||
> exported API intact so downstream pages compile unchanged. ~280–420 lines → split
|
> exported API intact so downstream pages compile unchanged. ~280–420 lines → split
|
||||||
> 2a/2b if over.
|
> 2a/2b if over.
|
||||||
|
|
||||||
- [ ] Migrate `frontend/src/components/SectionCard.tsx` (Box/Card/CardContent/Stack/Typography → shadcn `Card` family + Tailwind stack; comfortable density, `gap-4`).
|
- [x] Migrate `frontend/src/components/SectionCard.tsx` (Box/Card/CardContent/Stack/Typography → shadcn `Card` family + Tailwind stack; comfortable density, `gap-4`).
|
||||||
- [ ] Migrate `frontend/src/components/SelectionRailCard.tsx` (Box/Card/CardContent/Typography → `Card` + Tailwind; preserve `minHeight`/scrollable body/footer props).
|
- [x] Migrate `frontend/src/components/SelectionRailCard.tsx` (Box/Card/CardContent/Typography → `Card` + Tailwind; preserve `minHeight`/scrollable body/footer props).
|
||||||
- [ ] Migrate `frontend/src/components/TabbedCard.tsx` (Box/Card/CardContent/Tabs → shadcn `Tabs` (`TabsList`/`TabsTrigger`/`TabsContent`) on a `Card`).
|
- [x] Migrate `frontend/src/components/TabbedCard.tsx` (Box/Card/CardContent/Tabs → shadcn `Tabs` (`TabsList`/`TabsTrigger`/`TabsContent`) on a `Card`).
|
||||||
- [ ] Migrate `frontend/src/components/MetricCard.tsx` (Card/CardContent/Typography → shadcn `Card` + typography ramp: label `text-sm`, value `text-lg font-semibold`, subtext `text-xs text-muted-foreground`).
|
- [x] Migrate `frontend/src/components/MetricCard.tsx` (Card/CardContent/Typography → shadcn `Card` + typography ramp: label `text-sm`, value `text-lg font-semibold`, subtext `text-xs text-muted-foreground`).
|
||||||
- [ ] Migrate `frontend/src/components/DiskSpaceCard.tsx` (Box/Card/CardContent/Grid/LinearProgress/Stack/Typography → `Card` + CSS grid + shadcn `Progress`; preserve used/free/total/percent breakdown).
|
- [x] Migrate `frontend/src/components/DiskSpaceCard.tsx` (Box/Card/CardContent/Grid/LinearProgress/Stack/Typography → `Card` + CSS grid + shadcn `Progress`; preserve used/free/total/percent breakdown).
|
||||||
- [ ] Migrate `frontend/src/components/HoverEditButton.tsx` (`@mui/material` IconButton + `@mui/icons-material/EditOutlined` → `Button variant="ghost" size="icon"` + lucide `Pencil`; keep the hover-in visibility transition).
|
- [x] Migrate `frontend/src/components/HoverEditButton.tsx` (`@mui/material` IconButton + `@mui/icons-material/EditOutlined` → `Button variant="ghost" size="icon"` + lucide `Pencil`; keep the hover-in visibility transition).
|
||||||
- [ ] Migrate `frontend/src/components/DialogFooter.tsx` (Box/Button/DialogActions → `Button` row (`flex flex-row items-center gap-2`); preserve cancel/confirm/secondary-action props + busy/disabled labels).
|
- [x] Migrate `frontend/src/components/DialogFooter.tsx` (Box/Button/DialogActions → `Button` row (`flex flex-row items-center gap-2`); preserve cancel/confirm/secondary-action props + busy/disabled labels).
|
||||||
- [ ] Migrate `frontend/src/components/ConfirmDialog.tsx` (Dialog/DialogContent/DialogTitle/Stack/Typography → shadcn `Dialog` family + `DialogFooter` from this slice).
|
- [x] Migrate `frontend/src/components/ConfirmDialog.tsx` (Dialog/DialogContent/DialogTitle/Stack/Typography → shadcn `Dialog` family + `DialogFooter` from this slice).
|
||||||
- [ ] Migrate `frontend/src/components/LibraryOverview.tsx` (Card/CardContent/Grid/Stack/Typography → `Card` + responsive CSS grid `grid grid-cols-1 md:grid-cols-2 gap-4`).
|
- [x] Migrate `frontend/src/components/LibraryOverview.tsx` (Card/CardContent/Grid/Stack/Typography → `Card` + responsive CSS grid `grid grid-cols-1 md:grid-cols-2 gap-4`).
|
||||||
- [ ] Migrate `frontend/src/components/NowPlaying.tsx` (wrapper around `SessionActivityPanel`; keep the empty-state message contract) and migrate `frontend/src/components/SessionActivityPanel.tsx` (Button/Chip/Paper/Table family/Typography → `Button`/`Badge`/bordered surface/shadcn `Table` family; status → Badge variant mapping per design §2.3, healthy=`success`).
|
- [x] Migrate `frontend/src/components/NowPlaying.tsx` (wrapper around `SessionActivityPanel`; keep the empty-state message contract) and migrate `frontend/src/components/SessionActivityPanel.tsx` (Button/Chip/Paper/Table family/Typography → `Button`/`Badge`/bordered surface/shadcn `Table` family; status → Badge variant mapping per design §2.3, healthy=`success`).
|
||||||
- [ ] Add at least one behavioral component test per migrated block (co-located under the component's `__tests__/`), e.g. `MetricCard` renders label/value/subtext; status Badge variant mapping for `SessionActivityPanel`.
|
- [x] Add at least one behavioral component test per migrated block (co-located under the component's `__tests__/`), e.g. `MetricCard` renders label/value/subtext; status Badge variant mapping for `SessionActivityPanel`.
|
||||||
- [ ] **Exit gate:** all 11 shared components MUI-free (`grep -rlE '@mui/(material|icons-material)' src/components` returns none of these files); exported APIs unchanged so pages still compile; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
|
- [x] **Exit gate:** all 11 shared components MUI-free (`grep -rlE '@mui/(material|icons-material)' src/components` returns none of these files); exported APIs unchanged so pages still compile; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user