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:
Developer
2026-06-17 12:33:47 +00:00
parent dd778d8850
commit 109e74db41
23 changed files with 830 additions and 469 deletions
+27 -19
View File
@@ -1,12 +1,17 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
} from "@/components/ui/dialog";
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({
open,
title,
@@ -25,23 +30,26 @@ export function ConfirmDialog({
busy?: boolean;
}) {
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Stack>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</DialogContent>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</Dialog>
);
}
+41 -16
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Button, DialogActions } from "@mui/material";
import { Button } from "@/components/ui/button";
interface DialogFooterProps {
onCancel: () => void;
@@ -14,6 +14,28 @@ interface DialogFooterProps {
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({
onCancel,
cancelLabel = "Cancel",
@@ -27,20 +49,23 @@ export function DialogFooter({
secondaryAction,
}: DialogFooterProps) {
return (
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{secondaryAction}
<Button
variant={confirmVariant}
color={confirmColor}
disabled={confirmDisabled}
startIcon={confirmStartIcon}
onClick={onConfirm}
>
{confirmBusyLabel ?? confirmLabel}
</Button>
</Box>
</DialogActions>
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
{cancelLabel}
</Button>
{secondaryAction ? (
<div className="flex flex-row items-center gap-2">
{secondaryAction}
</div>
) : null}
<Button
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
disabled={confirmDisabled}
onClick={onConfirm}
>
{confirmStartIcon}
{confirmBusyLabel ?? confirmLabel}
</Button>
</div>
);
}
+45 -121
View File
@@ -1,12 +1,5 @@
import {
Box,
Card,
CardContent,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
interface Props {
used: number;
@@ -27,127 +20,58 @@ function formatBytes(bytes: number): string {
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.
*
* It intentionally keeps the progress bar inside the card so the capacity
* signal, raw byte values, and free-space breakdown stay visually grouped.
* Keeps the progress bar inside the card so the capacity signal, raw byte
* 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) {
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 (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
<Card className="h-full">
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-sm uppercase tracking-wide text-muted-foreground">
Disk space
</span>
<span className="text-lg font-semibold">{usedPct} used</span>
</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
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{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>
<span className="text-xs text-muted-foreground">
{cell.label}
</span>
<span className="text-sm font-semibold">{cell.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
+17 -12
View File
@@ -1,32 +1,37 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { IconButton } from "@mui/material";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
interface HoverEditButtonProps {
onClick: () => void;
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({
onClick,
label = "Edit",
}: HoverEditButtonProps) {
return (
<IconButton
className="rail-edit"
<Button
variant="ghost"
size="icon-sm"
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
aria-label={label}
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
sx={{
opacity: 0,
transition: "opacity 120ms ease",
color: "text.secondary",
}}
>
<EditOutlinedIcon fontSize="inherit" />
</IconButton>
<Pencil />
</Button>
);
}
+30 -29
View File
@@ -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";
interface Props {
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) {
const movieLibs = libraries.filter((l) => l.type === "movies");
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
Movie libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{movieLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
</div>
</div>
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
TV libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{tvLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
</Grid>
</div>
</div>
</div>
);
}
+15 -35
View File
@@ -1,4 +1,4 @@
import { Card, CardContent, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
interface Props {
label: string;
@@ -6,44 +6,24 @@ interface Props {
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) {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent
sx={{
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 }}
>
<Card className="h-full">
<CardContent className="flex h-full flex-col gap-1.5">
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
{label}
</Typography>
<Typography
variant="h5"
sx={{
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 }}
>
</span>
<span className="text-lg font-semibold leading-tight">{value}</span>
{subtext ? (
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
{subtext}
</Typography>
)}
</span>
) : null}
</CardContent>
</Card>
);
+20 -27
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
interface SectionCardProps {
title: string;
@@ -8,6 +8,13 @@ interface SectionCardProps {
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({
title,
description,
@@ -15,32 +22,18 @@ export function SectionCard({
children,
}: SectionCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.25}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
flexWrap: "wrap",
}}
>
<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>
<Card className="gap-4">
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-base font-semibold">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</div>
{children}
</CardContent>
</Card>
);
+19 -46
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Typography } from "@mui/material";
import { Card } from "@/components/ui/card";
interface SelectionRailCardProps {
title: string;
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
children: ReactNode;
footer?: ReactNode;
minHeight?: number;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
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({
title,
description,
children,
footer,
minHeight = 420,
contentSx,
bodySx,
}: SelectionRailCardProps) {
return (
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
<CardContent
sx={{
p: 0,
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>
<Card className="h-fit self-start py-0" style={{ minHeight }}>
<div className="flex flex-col" style={{ minHeight }}>
<div className="border-b bg-muted/50 px-4 py-3">
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</Box>
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
{footer ? (
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
{footer}
</Box>
) : null}
</CardContent>
</div>
<div className="flex-1 overflow-y-auto">{children}</div>
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
</div>
</Card>
);
}
+64 -135
View File
@@ -1,15 +1,13 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Typography,
} from "@mui/material";
} from "@/components/ui/table";
import type { NowPlayingSession } from "../types";
interface Props {
@@ -19,6 +17,23 @@ interface Props {
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 {
const normalized = String(state || "")
.trim()
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{
maxHeight: 280,
borderColor: "divider",
borderRadius: 1,
overflowX: "auto",
}}
>
<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>
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
<Table aria-label="Session activity details" className="min-w-[880px]">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead className="min-w-[160px]">User</TableHead>
<TableHead className="w-[82px]">State</TableHead>
<TableHead className="min-w-[140px]">Title / Type</TableHead>
<TableHead className="min-w-[140px]">Device</TableHead>
<TableHead className="w-[118px]">Transcoding</TableHead>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
<TableHead className="w-[150px]">Action</TableHead>
) : null}
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableRow className="bg-card hover:bg-card">
<TableCell
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)}
</Typography>
</span>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
className={onSelectSession ? "cursor-pointer" : undefined}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
<TableCell className="min-w-[160px]">
<div
className="truncate text-sm"
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
</div>
<div
className="truncate text-xs text-muted-foreground"
title={session.session_id}
>
{session.session_id}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
<TableCell className="whitespace-nowrap">
<Badge variant={sessionStateVariant(session.state)}>
{sessionLabel}
</Badge>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm" title={session.title || ""}>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
</div>
<div className="truncate text-xs text-muted-foreground">
{session.type || "—"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm">
{session.device || "Unknown device"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
<TableCell className="whitespace-nowrap">
<span className="text-sm">
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</span>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<TableCell className="whitespace-nowrap">
<Button
size="small"
variant="outlined"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
})}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
+18 -17
View File
@@ -1,38 +1,39 @@
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 {
value: string;
onChange: (value: string) => void;
tabs: ReactElement[];
children: ReactNode;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
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({
value,
onChange,
tabs,
children,
contentSx,
tabsSx,
}: TabbedCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 0 }}>
<Tabs
value={value}
onChange={(_, next) => onChange(String(next))}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
>
{tabs}
</Tabs>
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
</CardContent>
<Card className="gap-0 py-0">
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
<div className="border-b px-2">
<TabsList variant="line">{tabs}</TabsList>
</div>
<div className="p-4">{children}</div>
</Tabs>
</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");
});
});