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
+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>
);
}