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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user