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