30f1b6e6db
Apply the mobile-touch-target CSS class to 40 interactive elements across 12 files. The class applies min-height/min-width:44px only below md (max-width:767px), satisfying WCAG 2.5.5 / Apple HIG on touch devices. Desktop behavior is unchanged. Audit log (before -> after hit-area): - App.tsx: hamburger/dark-mode/sign-out (32/32/28 -> 44) - Dashboard.tsx: shortcut open/edit/delete (28 -> 44), enabled switch (18 -> 44) - Media.tsx: mobile pagination prev/next (28 -> 44) - FileBrowser.impl.tsx: 'Open Settings' alert button (28 -> 44) - UsersPage.impl.tsx: compose toolbar bold/italic/link/list (32 -> 44), attachment remove button (16 -> 44) - Settings.tsx: machine switch (18 -> 44), clear/add-machine buttons (28 -> 44), reset-db checkboxes x3 (16 -> 44) - Actions.tsx: 'Add action' button (28 -> 44) - ServicePage.tsx: service enabled switch (18 -> 44) - ServicesPage.tsx: service switch/open-link/delete-icon (18/28/32 -> 44) - ObservabilityPage.tsx: retry + 4 asChild link buttons (28 -> 44) - WidgetConfigDialog.tsx: 4 icon buttons (32 -> 44), 2 switches (18 -> 44), 2 add-widget buttons (28 -> 44) - SessionActivityPanel.tsx: 'Open in Users' button (28 -> 44) Deliberately skipped: default-size text buttons (32px, borderline), desktop-only sidebar toggle, DataTable internals (desktop-only below md), Select triggers. Dashboard anchor pills and HoverEditButton already had the class from Slices 1/2. No new tests (the class applies via @media which jsdom doesn't honor). 116 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R6, tasks slice 9).
189 lines
5.3 KiB
TypeScript
189 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"
|
|
className="mobile-touch-target"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onSelectSession(session);
|
|
}}
|
|
>
|
|
Open in Users
|
|
</Button>
|
|
</TableCell>
|
|
) : null}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
}
|