Files
manage/frontend/src/components/SessionActivityPanel.tsx
T
Developer 109e74db41 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.
2026-06-17 12:33:47 +00:00

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