109e74db41
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.
188 lines
5.3 KiB
TypeScript
188 lines
5.3 KiB
TypeScript
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import type { NowPlayingSession } from "../types";
|
|
|
|
interface Props {
|
|
sessions: NowPlayingSession[];
|
|
emptyMessage?: string;
|
|
selectedUserLabel?: string;
|
|
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()
|
|
.toLowerCase();
|
|
if (normalized === "playing") {
|
|
return "Playing";
|
|
}
|
|
if (normalized === "paused") {
|
|
return "Paused";
|
|
}
|
|
if (normalized === "idle") {
|
|
return "Idle";
|
|
}
|
|
return normalized
|
|
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
|
: "Unknown";
|
|
}
|
|
|
|
function buildStatusSummary(sessions: NowPlayingSession[]) {
|
|
const playing = sessions.filter(
|
|
(session) =>
|
|
String(session.state || "")
|
|
.trim()
|
|
.toLowerCase() === "playing",
|
|
).length;
|
|
const paused = sessions.filter(
|
|
(session) =>
|
|
String(session.state || "")
|
|
.trim()
|
|
.toLowerCase() === "paused",
|
|
).length;
|
|
const idle = sessions.filter(
|
|
(session) =>
|
|
String(session.state || "")
|
|
.trim()
|
|
.toLowerCase() === "idle",
|
|
).length;
|
|
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${playing} playing · ${paused} paused · ${idle} idle`;
|
|
}
|
|
|
|
export function SessionActivityPanel({
|
|
sessions,
|
|
emptyMessage = "No live sessions matched to this user.",
|
|
selectedUserLabel,
|
|
onSelectSession,
|
|
}: Props) {
|
|
const userFallback = selectedUserLabel || "Unknown user";
|
|
|
|
if (!sessions.length) {
|
|
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
|
|
}
|
|
|
|
return (
|
|
<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 ? (
|
|
<TableHead className="w-[150px]">Action</TableHead>
|
|
) : null}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<TableRow className="bg-card hover:bg-card">
|
|
<TableCell
|
|
colSpan={onSelectSession ? 6 : 5}
|
|
className="bg-card py-3"
|
|
>
|
|
<span className="text-xs text-muted-foreground">
|
|
{buildStatusSummary(sessions)}
|
|
</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
{sessions.map((session) => {
|
|
const sessionLabel = formatStateLabel(session.state);
|
|
return (
|
|
<TableRow
|
|
key={session.session_id}
|
|
className={onSelectSession ? "cursor-pointer" : undefined}
|
|
onClick={
|
|
onSelectSession ? () => onSelectSession(session) : undefined
|
|
}
|
|
>
|
|
<TableCell className="min-w-[160px]">
|
|
<div
|
|
className="truncate text-sm"
|
|
title={session.user || userFallback}
|
|
>
|
|
{session.user || userFallback}
|
|
</div>
|
|
<div
|
|
className="truncate text-xs text-muted-foreground"
|
|
title={session.session_id}
|
|
>
|
|
{session.session_id}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap">
|
|
<Badge variant={sessionStateVariant(session.state)}>
|
|
{sessionLabel}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="min-w-[140px]">
|
|
<div className="truncate text-sm" title={session.title || ""}>
|
|
{session.title || "(idle)"}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{session.type || "—"}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="min-w-[140px]">
|
|
<div className="truncate text-sm">
|
|
{session.device || "Unknown device"}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap">
|
|
<span className="text-sm">
|
|
{session.transcoding === "yes"
|
|
? session.transcoding_type
|
|
? `yes (${session.transcoding_type})`
|
|
: "yes"
|
|
: "no"}
|
|
</span>
|
|
</TableCell>
|
|
{onSelectSession ? (
|
|
<TableCell className="whitespace-nowrap">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onSelectSession(session);
|
|
}}
|
|
>
|
|
Open in Users
|
|
</Button>
|
|
</TableCell>
|
|
) : null}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
}
|