From 5484d3cefeb6d83d168e57bec862f5ccbdc4bd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Tue, 25 Aug 2026 21:26:18 +0200 Subject: [PATCH] Fix device/replay/session UX issues, add pagination, bump to 0.3.0 - Fix device rename input collapsing on mobile (fixed width vs w-full inside an auto-layout table column). - Add cascade-delete confirmation for sessions with a saved recording. - Fix header connection LED: derive state from scanning/device-count/ recording instead of the Buttplug client's raw connected flag; drop the label text and hide the indicator entirely when idle. - Add a per-device disconnect button (stop + remove from store, the closest equivalent Buttplug's protocol allows per device). - Fix replay ending early: duration now comes from the recording's actual durationMs, not the last event's timestamp. - Replay robustness: show which devices are being replayed to, reset actuators to zero on start/play/pause, fully disconnect devices and the whole client on stop/unmount, surface command failures via toast. - Stop flagging the header LED red during replay - recording is only for live sessions. - Add page-number pagination to recordings/sessions/devices lists. - Wordmark SEXY -> Sexy; add proper per-page s including dynamic titles for recording/session detail and replay pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- app/(app)/control/page.tsx | 3 + app/(app)/devices/page.tsx | 18 +- app/(app)/recordings/[id]/page.tsx | 9 +- app/(app)/recordings/[id]/replay/page.tsx | 8 + app/(app)/recordings/page.tsx | 18 +- app/(app)/sessions/[id]/page.tsx | 9 +- app/(app)/sessions/page.tsx | 18 +- app/(app)/stats/page.tsx | 2 + app/api/play-sessions/[id]/route.ts | 9 +- app/globals.css | 21 +- app/layout.tsx | 2 +- app/login/page.tsx | 5 +- app/manifest.ts | 4 +- components/control/ButtplugConsole.tsx | 16 ++ components/control/DeviceCard.tsx | 7 +- components/devices/DevicesTable.tsx | 4 +- components/layout/ConnectionStatus.tsx | 30 ++- components/layout/NavBar.tsx | 2 +- components/recordings/ReplayPlayer.tsx | 249 +++++++++++++++------- components/sessions/SessionsTable.tsx | 128 +++++++---- components/shared/PageNav.tsx | 53 +++++ lib/buttplug/player.ts | 53 +++-- lib/buttplug/store.ts | 8 +- lib/db/queries/devices.ts | 14 +- lib/db/queries/play-sessions.ts | 27 ++- lib/db/queries/recordings.ts | 27 ++- lib/pagination.ts | 13 ++ package.json | 2 +- 28 files changed, 585 insertions(+), 174 deletions(-) create mode 100644 components/shared/PageNav.tsx create mode 100644 lib/pagination.ts diff --git a/app/(app)/control/page.tsx b/app/(app)/control/page.tsx index ad783a7..68cfb5e 100644 --- a/app/(app)/control/page.tsx +++ b/app/(app)/control/page.tsx @@ -1,5 +1,8 @@ +import type { Metadata } from "next"; import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader"; +export const metadata: Metadata = { title: "Control" }; + export default function ControlPage() { return ( <div className="flex flex-col gap-6"> diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx index e6e9b47..d0fabfe 100644 --- a/app/(app)/devices/page.tsx +++ b/app/(app)/devices/page.tsx @@ -1,11 +1,20 @@ +import type { Metadata } from "next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DevicesTable } from "@/components/devices/DevicesTable"; -import { listDevices } from "@/lib/db/queries/devices"; +import { PageNav } from "@/components/shared/PageNav"; +import { listDevicesPage } from "@/lib/db/queries/devices"; +import { parsePage } from "@/lib/pagination"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Devices" }; -export default async function DevicesPage() { - const devices = await listDevices(); +export default async function DevicesPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const page = parsePage((await searchParams).page); + const { items: devices, pageSize, total } = await listDevicesPage(page); return ( <div className="flex flex-col gap-6"> @@ -17,8 +26,9 @@ export default async function DevicesPage() { <CardHeader> <CardTitle className="text-base">Known devices</CardTitle> </CardHeader> - <CardContent> + <CardContent className="flex flex-col gap-3"> <DevicesTable devices={devices} /> + <PageNav basePath="/devices" page={page} pageSize={pageSize} total={total} /> </CardContent> </Card> </div> diff --git a/app/(app)/recordings/[id]/page.tsx b/app/(app)/recordings/[id]/page.tsx index 46d9e9e..0ec54da 100644 --- a/app/(app)/recordings/[id]/page.tsx +++ b/app/(app)/recordings/[id]/page.tsx @@ -1,12 +1,19 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import type { Metadata } from "next"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { getRecording } from "@/lib/db/queries/recordings"; +import { getRecording, getRecordingName } from "@/lib/db/queries/recordings"; import { Play } from "lucide-react"; export const dynamic = "force-dynamic"; +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { + const { id } = await params; + const name = await getRecordingName(Number(id)); + return { title: name ?? "Recording" }; +} + function formatDuration(ms: number): string { const totalSeconds = Math.round(ms / 1000); return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`; diff --git a/app/(app)/recordings/[id]/replay/page.tsx b/app/(app)/recordings/[id]/replay/page.tsx index 1c1b8c1..f2f37fc 100644 --- a/app/(app)/recordings/[id]/replay/page.tsx +++ b/app/(app)/recordings/[id]/replay/page.tsx @@ -1,4 +1,12 @@ +import type { Metadata } from "next"; import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader"; +import { getRecordingName } from "@/lib/db/queries/recordings"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { + const { id } = await params; + const name = await getRecordingName(Number(id)); + return { title: name ? `Replay ${name}` : "Replay" }; +} export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; diff --git a/app/(app)/recordings/page.tsx b/app/(app)/recordings/page.tsx index e97c585..d1d8965 100644 --- a/app/(app)/recordings/page.tsx +++ b/app/(app)/recordings/page.tsx @@ -1,11 +1,20 @@ +import type { Metadata } from "next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { RecordingsTable } from "@/components/recordings/RecordingsTable"; -import { listRecordings } from "@/lib/db/queries/recordings"; +import { PageNav } from "@/components/shared/PageNav"; +import { listRecordingsPage } from "@/lib/db/queries/recordings"; +import { parsePage } from "@/lib/pagination"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Recordings" }; -export default async function RecordingsPage() { - const recordings = await listRecordings(); +export default async function RecordingsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const page = parsePage((await searchParams).page); + const { items: recordings, pageSize, total } = await listRecordingsPage(page); return ( <div className="flex flex-col gap-6"> @@ -17,8 +26,9 @@ export default async function RecordingsPage() { <CardHeader> <CardTitle className="text-base">Library</CardTitle> </CardHeader> - <CardContent> + <CardContent className="flex flex-col gap-3"> <RecordingsTable recordings={recordings} /> + <PageNav basePath="/recordings" page={page} pageSize={pageSize} total={total} /> </CardContent> </Card> </div> diff --git a/app/(app)/sessions/[id]/page.tsx b/app/(app)/sessions/[id]/page.tsx index 7760426..bd8d341 100644 --- a/app/(app)/sessions/[id]/page.tsx +++ b/app/(app)/sessions/[id]/page.tsx @@ -1,11 +1,18 @@ import { notFound } from "next/navigation"; +import type { Metadata } from "next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; -import { getPlaySessionDetail } from "@/lib/db/queries/play-sessions"; +import { getPlaySessionDetail, getPlaySessionName } from "@/lib/db/queries/play-sessions"; import { getSessionTimeline } from "@/lib/db/queries/stats"; export const dynamic = "force-dynamic"; +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { + const { id } = await params; + const name = await getPlaySessionName(Number(id)); + return { title: name ?? `Session #${id}` }; +} + function formatDuration(ms: number | null): string { if (ms === null) return "-"; const totalSeconds = Math.round(ms / 1000); diff --git a/app/(app)/sessions/page.tsx b/app/(app)/sessions/page.tsx index 216cdde..881ad51 100644 --- a/app/(app)/sessions/page.tsx +++ b/app/(app)/sessions/page.tsx @@ -1,11 +1,20 @@ +import type { Metadata } from "next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SessionsTable } from "@/components/sessions/SessionsTable"; -import { listPlaySessions } from "@/lib/db/queries/play-sessions"; +import { PageNav } from "@/components/shared/PageNav"; +import { listPlaySessionsPage } from "@/lib/db/queries/play-sessions"; +import { parsePage } from "@/lib/pagination"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Sessions" }; -export default async function SessionsPage() { - const sessions = [...(await listPlaySessions())].reverse(); +export default async function SessionsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const page = parsePage((await searchParams).page); + const { items: sessions, pageSize, total } = await listPlaySessionsPage(page); return ( <div className="flex flex-col gap-6"> @@ -17,8 +26,9 @@ export default async function SessionsPage() { <CardHeader> <CardTitle className="text-base">History</CardTitle> </CardHeader> - <CardContent> + <CardContent className="flex flex-col gap-3"> <SessionsTable sessions={sessions} /> + <PageNav basePath="/sessions" page={page} pageSize={pageSize} total={total} /> </CardContent> </Card> </div> diff --git a/app/(app)/stats/page.tsx b/app/(app)/stats/page.tsx index 80b6d55..87d7c42 100644 --- a/app/(app)/stats/page.tsx +++ b/app/(app)/stats/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; @@ -5,6 +6,7 @@ import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats" import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats"; export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Stats" }; export default async function StatsPage() { const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([ diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts index 150bc9f..ccd7804 100644 --- a/app/api/play-sessions/[id]/route.ts +++ b/app/api/play-sessions/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions"; +import { getRecordingsForSession } from "@/lib/db/queries/recordings"; const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) }); @@ -22,16 +23,18 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st return NextResponse.json({ playSession: updated }); } -export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const cascade = new URL(req.url).searchParams.get("cascade") === "true"; try { - await deletePlaySession(Number(id)); + await deletePlaySession(Number(id), { cascade }); return NextResponse.json({ ok: true }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) { + const blockingRecordings = await getRecordingsForSession(Number(id)); return NextResponse.json( - { error: "cannot delete a session that a saved recording still references" }, + { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings }, { status: 409 }, ); } diff --git a/app/globals.css b/app/globals.css index 666358b..b6ce796 100644 --- a/app/globals.css +++ b/app/globals.css @@ -176,6 +176,17 @@ .bp-led[data-on="true"] { background: var(--primary); } +/* Header connection indicator: state-driven color, distinct from the + transmit-dot's boolean data-on above. */ +.bp-led[data-state="scanning"] { + background: var(--muted-foreground); +} +.bp-led[data-state="connected"] { + background: var(--primary); +} +.bp-led[data-state="recording"] { + background: var(--destructive); +} /* Thin panel seam - a hardware "trim line," not a plain UI divider. */ .bp-hairline { @@ -193,15 +204,21 @@ @keyframes bp-pulse-glow { 0%, 100% { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 55%, transparent); } 50% { - box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent); + box-shadow: 0 0 0 6px color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 0%, transparent); } } .bp-pulse { animation: bp-pulse-glow 2s ease-in-out infinite; } +.bp-led[data-state="scanning"] { + --bp-pulse-color: var(--muted-foreground); +} +.bp-led[data-state="recording"] { + --bp-pulse-color: var(--destructive); +} @media (prefers-reduced-motion: reduce) { .bp-pulse { diff --git a/app/layout.tsx b/app/layout.tsx index 94cbe8e..23bfcb6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,7 +10,7 @@ const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-displ const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" }); export const metadata: Metadata = { - title: "SEXY", + title: { default: "Sexy", template: "%s · Sexy" }, description: "Bluetooth toy control console", }; diff --git a/app/login/page.tsx b/app/login/page.tsx index bc3b0cb..303768f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,8 +1,11 @@ import { Suspense } from "react"; +import type { Metadata } from "next"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { LoginForm } from "@/components/auth/LoginForm"; import { BrandMark } from "@/components/layout/BrandMark"; +export const metadata: Metadata = { title: "Log in" }; + export default function LoginPage() { return ( <div className="flex min-h-dvh items-center justify-center p-4"> @@ -10,7 +13,7 @@ export default function LoginPage() { <CardHeader> <div className="flex items-center gap-2"> <BrandMark size={32} /> - <CardTitle className="font-heading bp-gradient-text text-2xl">SEXY</CardTitle> + <CardTitle className="font-heading bp-gradient-text text-2xl">Sexy</CardTitle> </div> <CardDescription>Enter the shared access secret to continue.</CardDescription> </CardHeader> diff --git a/app/manifest.ts b/app/manifest.ts index 7c3af8b..225e90e 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -2,8 +2,8 @@ import type { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { - name: "SEXY", - short_name: "SEXY", + name: "Sexy", + short_name: "Sexy", description: "Bluetooth toy control console", start_url: "/", display: "standalone", diff --git a/components/control/ButtplugConsole.tsx b/components/control/ButtplugConsole.tsx index d9af230..82a933a 100644 --- a/components/control/ButtplugConsole.tsx +++ b/components/control/ButtplugConsole.tsx @@ -45,6 +45,8 @@ export function ButtplugConsole() { const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const batteryLevels = useButtplugStore((s) => s.batteryLevels); const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel); + const removeDevice = useButtplugStore((s) => s.removeDevice); + const setRecording = useButtplugStore((s) => s.setRecording); const storeError = useButtplugStore((s) => s.error); const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null); @@ -136,6 +138,7 @@ export function ButtplugConsole() { sessionDeviceIdByDeviceIndex, }); setElapsedMs(0); + setRecording(true); } catch { toast.error("Could not start session."); } finally { @@ -147,6 +150,7 @@ export function ButtplugConsole() { if (!activeSession) return; setSessionBusy(true); eventBuffer.stop(); + setRecording(false); await fetch(`/api/play-sessions/${activeSession.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -197,6 +201,17 @@ export function ButtplugConsole() { device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0)); } + async function handleDisconnectDevice(device: ConnectedDeviceInfo) { + // Buttplug's protocol has no per-device disconnect message - only a + // whole-client disconnect() and stop() (halts actuators). Stopping it and + // removing it from local state is the closest equivalent: the card + // disappears and it stops receiving commands, though the underlying BLE + // link may persist until the whole client disconnects. + const liveDevice = await getDevice(device.index); + await liveDevice?.stop().catch(() => {}); + removeDevice(device.index); + } + if (!supported) { return ( <Card className="bp-glass"> @@ -249,6 +264,7 @@ export function ButtplugConsole() { } onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)} onStop={() => void handleStopDevice(device)} + onDisconnect={() => void handleDisconnectDevice(device)} /> ))} </div> diff --git a/components/control/DeviceCard.tsx b/components/control/DeviceCard.tsx index 647f5e2..fd08201 100644 --- a/components/control/DeviceCard.tsx +++ b/components/control/DeviceCard.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ActuatorSlider } from "./ActuatorSlider"; import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; -import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react"; +import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning, Unlink } from "lucide-react"; interface DeviceCardProps { device: ConnectedDeviceInfo; @@ -13,6 +13,7 @@ interface DeviceCardProps { transmittingFeatureIndex: number | null; onActuatorChange: (featureIndex: number, value: number) => void; onStop: () => void; + onDisconnect: () => void; } function BatteryIndicator({ level }: { level: number }) { @@ -32,6 +33,7 @@ export function DeviceCard({ transmittingFeatureIndex, onActuatorChange, onStop, + onDisconnect, }: DeviceCardProps) { return ( <Card className="bp-glass"> @@ -47,6 +49,9 @@ export function DeviceCard({ <Button variant="outline" size="sm" onClick={onStop}> Stop </Button> + <Button variant="ghost" size="icon-sm" onClick={onDisconnect} aria-label="Disconnect device"> + <Unlink className="size-3.5" /> + </Button> </div> </CardHeader> <div className="bp-hairline mx-4" /> diff --git a/components/devices/DevicesTable.tsx b/components/devices/DevicesTable.tsx index 1a82ba0..a55112e 100644 --- a/components/devices/DevicesTable.tsx +++ b/components/devices/DevicesTable.tsx @@ -38,8 +38,8 @@ function DeviceNameCell({ device }: { device: DeviceRow }) { return ( <div className="flex items-center gap-2"> - <Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 max-w-48" /> - <Button size="sm" variant="outline" onClick={handleSave} disabled={saving}> + <Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 w-40 min-w-40" /> + <Button size="sm" variant="outline" onClick={handleSave} disabled={saving} className="shrink-0"> Save </Button> </div> diff --git a/components/layout/ConnectionStatus.tsx b/components/layout/ConnectionStatus.tsx index 545cf5d..e13460c 100644 --- a/components/layout/ConnectionStatus.tsx +++ b/components/layout/ConnectionStatus.tsx @@ -1,18 +1,28 @@ "use client"; import { useButtplugStore } from "@/lib/buttplug/store"; -import { cn } from "@/lib/utils"; + +type IndicatorState = "scanning" | "connected" | "recording"; export function ConnectionStatus() { - const connected = useButtplugStore((s) => s.connected); + const scanning = useButtplugStore((s) => s.scanning); + const recording = useButtplugStore((s) => s.recording); const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length); - return ( - <div className="flex items-center gap-2 rounded-md border border-border bg-card/60 px-3 py-1 text-xs"> - <span className={cn("bp-led", connected && "bp-pulse")} data-on={connected} aria-hidden /> - <span className="bp-readout text-muted-foreground"> - {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "not connected"} - </span> - </div> - ); + // Priority: an active recording is the most important thing to surface, + // then whether toys are actually connected, then a bare scan-in-progress - + // this is deliberately independent of the Buttplug client's own `connected` + // flag, which goes true as soon as the embedded server initializes (i.e. as + // soon as scanning starts), not when a device is actually paired. + const state: IndicatorState | null = recording + ? "recording" + : deviceCount > 0 + ? "connected" + : scanning + ? "scanning" + : null; + + if (!state) return null; + + return <span className="bp-led bp-pulse" data-state={state} aria-hidden />; } diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx index 431d9d2..0fa6f7d 100644 --- a/components/layout/NavBar.tsx +++ b/components/layout/NavBar.tsx @@ -57,7 +57,7 @@ export function NavBar() { <div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4"> <Link href="/" className="mr-2 flex items-center gap-2"> <BrandMark size={26} /> - <span className="font-heading bp-gradient-text text-lg font-semibold">SEXY</span> + <span className="font-heading bp-gradient-text text-lg font-semibold">Sexy</span> </Link> <nav className="hidden items-center gap-1 md:flex"> diff --git a/components/recordings/ReplayPlayer.tsx b/components/recordings/ReplayPlayer.tsx index cef166c..6849712 100644 --- a/components/recordings/ReplayPlayer.tsx +++ b/components/recordings/ReplayPlayer.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { DeviceScanPanel } from "@/components/control/DeviceScanPanel"; import { DeviceRemapDialog } from "./DeviceRemapDialog"; -import { getButtplugClientHandle, startScanning, stopScanning } from "@/lib/buttplug/client"; +import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client"; import { eventBuffer } from "@/lib/buttplug/event-buffer"; import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player"; import { useButtplugStore } from "@/lib/buttplug/store"; @@ -35,13 +35,19 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { const router = useRouter(); const scanning = useButtplugStore((s) => s.scanning); const devices = useButtplugStore((s) => s.devices); + const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); + const removeDevice = useButtplugStore((s) => s.removeDevice); const connectedDevices = useMemo(() => Object.values(devices), [devices]); const [data, setData] = useState<RecordingResponse | null>(null); const [showRemap, setShowRemap] = useState(false); + const [starting, setStarting] = useState(false); const [player, setPlayer] = useState<RecordingPlayer | null>(null); const [playing, setPlaying] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); + const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]); + const [replayDeviceIndexes, setReplayDeviceIndexes] = useState<number[]>([]); + const [activeSessionId, setActiveSessionId] = useState<number | null>(null); useEffect(() => { fetch(`/api/recordings/${recordingId}`) @@ -50,69 +56,111 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { .catch(() => toast.error("Could not load recording")); }, [recordingId]); + useEffect(() => { + return () => { + void disconnectAll(); + }; + }, []); + + async function resetReplayDevices() { + await Promise.all( + replayDeviceIndexes.map(async (deviceIndex) => { + const liveDevice = await getDevice(deviceIndex); + await liveDevice?.stop().catch(() => {}); + devices[deviceIndex]?.actuators.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0)); + }), + ); + } + async function handleConfirmRemap(mapping: Map<number, number>) { if (!data) return; setShowRemap(false); + setStarting(true); - const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId)); - const body = { - kind: "replay" as const, - replayedRecordingId: data.recording.id, - devices: orderedSlots.map((slot) => { + try { + const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId)); + const targets = orderedSlots.map((slot) => { const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const device = connectedDevices.find((d) => d.index === deviceIndex)!; - return { slotLabel: slot.slotLabel, bleName: device.name }; - }), - }; + return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name }; + }); - const res = await fetch("/api/play-sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - toast.error("Could not start replay session"); - return; - } - const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } = - await res.json(); + const body = { + kind: "replay" as const, + replayedRecordingId: data.recording.id, + devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })), + }; - eventBuffer.start(result.session.id, result.session.startedAt); - const sessionDeviceIdToDeviceIndex = new Map<number, number>(); - orderedSlots.forEach((slot, i) => { - const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; - const newSessionDeviceId = result.sessionDevices[i]?.id; - if (newSessionDeviceId !== undefined) { - eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId); - sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex); + const res = await fetch("/api/play-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + toast.error("Could not start replay session"); + return; } - }); + const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } = + await res.json(); - const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>( - connectedDevices.map((d) => [d.index, d.actuators]), - ); + eventBuffer.start(result.session.id, result.session.startedAt); + const sessionDeviceIdToDeviceIndex = new Map<number, number>(); + orderedSlots.forEach((slot, i) => { + const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; + const newSessionDeviceId = result.sessionDevices[i]?.id; + if (newSessionDeviceId !== undefined) { + eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId); + sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex); + } + }); - const { runtime } = await getButtplugClientHandle(); - const instance = new RecordingPlayer({ - events: data.events, - sessionDeviceIdToDeviceIndex, - actuatorsByDeviceIndex, - runtime, - onProgress: (elapsed) => setElapsedMs(elapsed), - onComplete: async () => { - setPlaying(false); - eventBuffer.stop(); - await fetch(`/api/play-sessions/${result.session.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "completed" }), - }); - toast.success("Replay finished"); - }, - }); - setPlayer(instance); - instance.play(); - setPlaying(true); + const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>( + connectedDevices.map((d) => [d.index, d.actuators]), + ); + + // Reset every actuator on every device that will take part in this replay before + // the first scheduled event fires, so playback always starts from a known-zero + // state rather than whatever intensity was left over from manual control. + const deviceIndexes = new Set(sessionDeviceIdToDeviceIndex.values()); + await Promise.all( + [...deviceIndexes].map(async (deviceIndex) => { + const liveDevice = await getDevice(deviceIndex); + await liveDevice?.stop().catch(() => {}); + actuatorsByDeviceIndex.get(deviceIndex)?.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0)); + }), + ); + + const { runtime } = await getButtplugClientHandle(); + const instance = new RecordingPlayer({ + events: data.events, + durationMs: data.recording.durationMs, + sessionDeviceIdToDeviceIndex, + actuatorsByDeviceIndex, + runtime, + onProgress: (elapsed) => setElapsedMs(elapsed), + onError: (message) => toast.error(`Replay command failed: ${message}`), + onComplete: async () => { + setPlaying(false); + eventBuffer.stop(); + await fetch(`/api/play-sessions/${result.session.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "completed" }), + }).catch(() => {}); + toast.success("Replay finished"); + }, + }); + setReplayTargets(targets); + setReplayDeviceIndexes([...deviceIndexes]); + setActiveSessionId(result.session.id); + setPlayer(instance); + instance.play(); + setPlaying(true); + } catch { + toast.error("Could not start replay"); + } finally { + setStarting(false); + } } if (!data) { @@ -136,41 +184,98 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { <DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} /> <Button onClick={async () => { - await getButtplugClientHandle(); - setShowRemap(true); + try { + await getButtplugClientHandle(); + setShowRemap(true); + } catch { + toast.error("Could not connect to Buttplug client"); + } }} - disabled={connectedDevices.length === 0} + disabled={connectedDevices.length === 0 || starting} > - Match devices & replay + {starting ? "Starting..." : "Match devices & replay"} </Button> </div> </> ) : ( <div className="flex flex-col gap-3"> + {replayTargets.length > 0 && ( + <div className="flex flex-wrap gap-2"> + {replayTargets.map((t) => ( + <span + key={t.slotLabel} + className="bp-readout rounded-md border border-border bg-card/60 px-2 py-1 text-xs text-muted-foreground" + > + {t.slotLabel} → {t.deviceName} + </span> + ))} + </div> + )} <Slider value={[elapsedMs]} max={data.recording.durationMs} onValueChange={([v]) => player.seek(v)} /> <div className="flex items-center justify-between"> - <span className="text-xs text-muted-foreground"> + <span className="bp-readout text-xs text-muted-foreground"> {formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)} </span> - <Button - size="icon" - variant="outline" - onClick={() => { - if (playing) { - player.pause(); + <div className="flex items-center gap-2"> + <Button + size="icon" + variant="outline" + onClick={async () => { + if (playing) { + player.pause(); + setPlaying(false); + } else { + player.play(); + setPlaying(true); + } + // Toggling either way leaves the toy holding whatever intensity was + // last sent - zero it out so pause always actually stops the device, + // and resume always starts from a clean, known state. + await resetReplayDevices(); + }} + aria-label={playing ? "Pause" : "Play"} + > + {playing ? <Pause className="size-4" /> : <Play className="size-4" />} + </Button> + <Button + size="sm" + variant="ghost" + onClick={async () => { + player.stop(); setPlaying(false); - } else { - player.play(); - setPlaying(true); - } - }} - > - {playing ? <Pause className="size-4" /> : <Play className="size-4" />} - </Button> + setElapsedMs(0); + eventBuffer.stop(); + if (activeSessionId !== null) { + await fetch(`/api/play-sessions/${activeSessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "aborted" }), + }).catch(() => {}); + } + // Fully disconnect every device that took part in this replay, + // not just stop the player - see handleDisconnectDevice in + // ButtplugConsole for why "stop + remove from store" is the + // closest equivalent Buttplug's protocol allows per-device. + await Promise.all( + replayDeviceIndexes.map(async (deviceIndex) => { + const liveDevice = await getDevice(deviceIndex); + await liveDevice?.stop().catch(() => {}); + removeDevice(deviceIndex); + }), + ); + setPlayer(null); + setReplayTargets([]); + setReplayDeviceIndexes([]); + setActiveSessionId(null); + }} + > + Stop + </Button> + </div> </div> </div> )} diff --git a/components/sessions/SessionsTable.tsx b/components/sessions/SessionsTable.tsx index 93be365..ff0a8c2 100644 --- a/components/sessions/SessionsTable.tsx +++ b/components/sessions/SessionsTable.tsx @@ -1,13 +1,27 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +interface BlockingRecording { + id: number; + name: string; +} + export interface SessionRow { id: number; name: string | null; @@ -27,6 +41,8 @@ function formatDuration(ms: number | null): string { export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { const router = useRouter(); + const [conflict, setConflict] = useState<{ id: number; recordings: BlockingRecording[] } | null>(null); + const [deleting, setDeleting] = useState(false); async function handleDelete(id: number) { const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" }); @@ -34,7 +50,22 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { toast.success("Session deleted"); router.refresh(); } else if (res.status === 409) { - toast.error("A saved recording still references this session"); + const body: { recordings?: BlockingRecording[] } = await res.json().catch(() => ({})); + setConflict({ id, recordings: body.recordings ?? [] }); + } else { + toast.error("Could not delete session"); + } + } + + async function handleCascadeDelete() { + if (!conflict) return; + setDeleting(true); + const res = await fetch(`/api/play-sessions/${conflict.id}?cascade=true`, { method: "DELETE" }); + setDeleting(false); + if (res.ok) { + toast.success("Session and recording deleted"); + setConflict(null); + router.refresh(); } else { toast.error("Could not delete session"); } @@ -45,41 +76,66 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { } return ( - <Table> - <TableHeader> - <TableRow> - <TableHead>Session</TableHead> - <TableHead>Kind</TableHead> - <TableHead>Status</TableHead> - <TableHead>Started</TableHead> - <TableHead>Duration</TableHead> - <TableHead className="text-right">Actions</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {sessions.map((s) => ( - <TableRow key={s.id}> - <TableCell className="font-medium"> - <Link href={`/sessions/${s.id}`} className="hover:underline"> - {s.name ?? `Session #${s.id}`} - </Link> - </TableCell> - <TableCell> - <Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge> - </TableCell> - <TableCell className="text-muted-foreground">{s.status}</TableCell> - <TableCell className="bp-readout text-muted-foreground"> - {new Date(s.startedAt).toLocaleString()} - </TableCell> - <TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell> - <TableCell className="text-right"> - <Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete"> - <Trash2 className="size-3.5" /> - </Button> - </TableCell> + <> + <Table> + <TableHeader> + <TableRow> + <TableHead>Session</TableHead> + <TableHead>Kind</TableHead> + <TableHead>Status</TableHead> + <TableHead>Started</TableHead> + <TableHead>Duration</TableHead> + <TableHead className="text-right">Actions</TableHead> </TableRow> - ))} - </TableBody> - </Table> + </TableHeader> + <TableBody> + {sessions.map((s) => ( + <TableRow key={s.id}> + <TableCell className="font-medium"> + <Link href={`/sessions/${s.id}`} className="hover:underline"> + {s.name ?? `Session #${s.id}`} + </Link> + </TableCell> + <TableCell> + <Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge> + </TableCell> + <TableCell className="text-muted-foreground">{s.status}</TableCell> + <TableCell className="bp-readout text-muted-foreground"> + {new Date(s.startedAt).toLocaleString()} + </TableCell> + <TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell> + <TableCell className="text-right"> + <Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete"> + <Trash2 className="size-3.5" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + <Dialog open={conflict !== null} onOpenChange={(open) => !open && setConflict(null)}> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete session and recording?</DialogTitle> + <DialogDescription> + This session has a saved recording + {conflict && conflict.recordings.length > 0 + ? ` (${conflict.recordings.map((r) => r.name).join(", ")})` + : ""}{" "} + that still points to it. Deleting the session will also delete that recording. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="ghost" onClick={() => setConflict(null)}> + Cancel + </Button> + <Button variant="destructive" onClick={() => void handleCascadeDelete()} disabled={deleting}> + {deleting ? "Deleting..." : "Delete both"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ); } diff --git a/components/shared/PageNav.tsx b/components/shared/PageNav.tsx new file mode 100644 index 0000000..a5025e4 --- /dev/null +++ b/components/shared/PageNav.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface PageNavProps { + basePath: string; + page: number; + pageSize: number; + total: number; +} + +export function PageNav({ basePath, page, pageSize, total }: PageNavProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (totalPages <= 1) return null; + + const from = total === 0 ? 0 : (page - 1) * pageSize + 1; + const to = Math.min(page * pageSize, total); + + return ( + <div className="flex items-center justify-between gap-4 pt-2"> + <span className="bp-readout text-xs text-muted-foreground"> + {from}-{to} of {total} + </span> + <div className="flex items-center gap-2"> + {page > 1 ? ( + <Button asChild variant="outline" size="icon-sm"> + <Link href={`${basePath}?page=${page - 1}`} aria-label="Previous page"> + <ChevronLeft className="size-3.5" /> + </Link> + </Button> + ) : ( + <Button variant="outline" size="icon-sm" disabled aria-label="Previous page"> + <ChevronLeft className="size-3.5" /> + </Button> + )} + <span className="bp-readout text-xs text-muted-foreground"> + {page} / {totalPages} + </span> + {page < totalPages ? ( + <Button asChild variant="outline" size="icon-sm"> + <Link href={`${basePath}?page=${page + 1}`} aria-label="Next page"> + <ChevronRight className="size-3.5" /> + </Link> + </Button> + ) : ( + <Button variant="outline" size="icon-sm" disabled aria-label="Next page"> + <ChevronRight className="size-3.5" /> + </Button> + )} + </div> + </div> + ); +} diff --git a/lib/buttplug/player.ts b/lib/buttplug/player.ts index cc3041b..cf17163 100644 --- a/lib/buttplug/player.ts +++ b/lib/buttplug/player.ts @@ -14,6 +14,11 @@ export interface RecordingEventRow { export interface PlayerOptions { events: RecordingEventRow[]; + /** The recording's actual duration (recordings.durationMs from the DB) - this is the source + * of truth for playback length, NOT the last event's timestamp: a recording can run for a + * while after its last command (e.g. the user stopped the toy but let the session continue), + * so deriving duration from events would end playback early and misreport it as "finished". */ + durationMs: number; /** Recording's session_device_id -> currently-connected device index, from the remap step. */ sessionDeviceIdToDeviceIndex: Map<number, number>; actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>; @@ -21,6 +26,7 @@ export interface PlayerOptions { speed?: number; onProgress?: (elapsedMs: number, durationMs: number) => void; onComplete?: () => void; + onError?: (message: string) => void; } /** @@ -37,7 +43,7 @@ export class RecordingPlayer { private playing = false; constructor(private readonly options: PlayerOptions) { - this.durationMs = options.events.at(-1)?.tsMs ?? 0; + this.durationMs = options.durationMs; } get isPlaying(): boolean { @@ -108,29 +114,32 @@ export class RecordingPlayer { const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId); if (deviceIndex === undefined) return; - const device = await getDevice(deviceIndex); - if (!device) return; + try { + const device = await getDevice(deviceIndex); + if (!device) return; - eventBuffer.record({ - deviceIndex, - commandType: event.commandType, - featureIndex: event.featureIndex, - value: event.value, - durationMs: event.durationMs ?? undefined, - }); + if (event.commandType === "stop") { + await device.stop(); + } else { + const actuator = this.options.actuatorsByDeviceIndex + .get(deviceIndex) + ?.find((a) => a.featureIndex === event.featureIndex); + const feature = findFeature(device, event.featureIndex); + if (!actuator || !feature) return; - if (event.commandType === "stop") { - await device.stop(); - return; + const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined); + await feature.runOutput(cmd); + } + + eventBuffer.record({ + deviceIndex, + commandType: event.commandType, + featureIndex: event.featureIndex, + value: event.value, + durationMs: event.durationMs ?? undefined, + }); + } catch (err) { + this.options.onError?.(err instanceof Error ? err.message : String(err)); } - - const actuator = this.options.actuatorsByDeviceIndex - .get(deviceIndex) - ?.find((a) => a.featureIndex === event.featureIndex); - const feature = findFeature(device, event.featureIndex); - if (!actuator || !feature) return; - - const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined); - await feature.runOutput(cmd); } } diff --git a/lib/buttplug/store.ts b/lib/buttplug/store.ts index ba5126e..4524502 100644 --- a/lib/buttplug/store.ts +++ b/lib/buttplug/store.ts @@ -7,6 +7,8 @@ export const actuatorKey = (deviceIndex: number, featureIndex: number): string = interface ButtplugStoreState { connected: boolean; scanning: boolean; + /** Whether a play session is actively being recorded (see ButtplugConsole's session lifecycle). */ + recording: boolean; devices: Record<number, ConnectedDeviceInfo>; /** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */ actuatorValues: Record<string, number>; @@ -15,6 +17,7 @@ interface ButtplugStoreState { error: string | null; setConnected: (connected: boolean) => void; setScanning: (scanning: boolean) => void; + setRecording: (recording: boolean) => void; upsertDevice: (device: ConnectedDeviceInfo) => void; removeDevice: (index: number) => void; setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void; @@ -26,12 +29,14 @@ interface ButtplugStoreState { export const useButtplugStore = create<ButtplugStoreState>((set) => ({ connected: false, scanning: false, + recording: false, devices: {}, actuatorValues: {}, batteryLevels: {}, error: null, setConnected: (connected) => set({ connected }), setScanning: (scanning) => set({ scanning }), + setRecording: (recording) => set({ recording }), upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })), removeDevice: (index) => set((s) => { @@ -48,5 +53,6 @@ export const useButtplugStore = create<ButtplugStoreState>((set) => ({ setBatteryLevel: (deviceIndex, level) => set((s) => ({ batteryLevels: { ...s.batteryLevels, [deviceIndex]: level } })), setError: (message) => set({ error: message }), - reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {}, batteryLevels: {} }), + reset: () => + set({ connected: false, scanning: false, recording: false, devices: {}, actuatorValues: {}, batteryLevels: {} }), })); diff --git a/lib/db/queries/devices.ts b/lib/db/queries/devices.ts index 6f250c5..1756fc2 100644 --- a/lib/db/queries/devices.ts +++ b/lib/db/queries/devices.ts @@ -1,11 +1,23 @@ -import { eq } from "drizzle-orm"; +import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { devices, type DeviceCapabilities } from "@/lib/db/schema"; +import { PAGE_SIZE, type Page } from "@/lib/pagination"; export async function listDevices() { return db.select().from(devices).orderBy(devices.lastConnectedAt); } +export async function listDevicesPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof devices.$inferSelect>> { + const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(devices); + const items = await db + .select() + .from(devices) + .orderBy(desc(devices.lastConnectedAt)) + .limit(pageSize) + .offset((page - 1) * pageSize); + return { items, page, pageSize, total: count }; +} + export async function getDevice(id: number) { const [row] = await db.select().from(devices).where(eq(devices.id, id)); return row; diff --git a/lib/db/queries/play-sessions.ts b/lib/db/queries/play-sessions.ts index 06197a1..72cb4e2 100644 --- a/lib/db/queries/play-sessions.ts +++ b/lib/db/queries/play-sessions.ts @@ -1,9 +1,10 @@ -import { eq } from "drizzle-orm"; +import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; -import { playSessions, sessionDevices, devices } from "@/lib/db/schema"; +import { playSessions, sessionDevices, devices, recordings } from "@/lib/db/schema"; import { upsertDeviceByBleName } from "./devices"; import { incrementPlayCount } from "./recordings"; import type { DeviceCapabilities } from "@/lib/db/schema"; +import { PAGE_SIZE, type Page } from "@/lib/pagination"; export interface StartSessionDeviceInput { slotLabel: string; @@ -86,6 +87,23 @@ export async function listPlaySessions() { return db.select().from(playSessions).orderBy(playSessions.startedAt); } +export async function listPlaySessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof playSessions.$inferSelect>> { + const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(playSessions); + const items = await db + .select() + .from(playSessions) + .orderBy(desc(playSessions.startedAt)) + .limit(pageSize) + .offset((page - 1) * pageSize); + return { items, page, pageSize, total: count }; +} + +/** Lightweight name-only lookup for page titles - avoids getPlaySessionDetail's device join. */ +export async function getPlaySessionName(id: number): Promise<string | null | undefined> { + const [row] = await db.select({ name: playSessions.name }).from(playSessions).where(eq(playSessions.id, id)); + return row?.name; +} + export async function getPlaySessionDetail(id: number) { const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id)); if (!session) return undefined; @@ -107,6 +125,9 @@ export async function getPlaySessionDetail(id: number) { return { session, devices: sessionDeviceRows }; } -export async function deletePlaySession(id: number) { +export async function deletePlaySession(id: number, options?: { cascade?: boolean }) { + if (options?.cascade) { + await db.delete(recordings).where(eq(recordings.sourcePlaySessionId, id)); + } await db.delete(playSessions).where(eq(playSessions.id, id)); } diff --git a/lib/db/queries/recordings.ts b/lib/db/queries/recordings.ts index c191c34..c6cbc99 100644 --- a/lib/db/queries/recordings.ts +++ b/lib/db/queries/recordings.ts @@ -1,7 +1,8 @@ -import { desc, eq } from "drizzle-orm"; +import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema"; import { getEventsForSession } from "./session-events"; +import { PAGE_SIZE, type Page } from "@/lib/pagination"; /** * A recording is a thin pointer over an already-captured play_session, not a @@ -60,6 +61,30 @@ export async function listRecordings() { return db.select().from(recordings).orderBy(desc(recordings.createdAt)); } +export async function listRecordingsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof recordings.$inferSelect>> { + const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(recordings); + const items = await db + .select() + .from(recordings) + .orderBy(desc(recordings.createdAt)) + .limit(pageSize) + .offset((page - 1) * pageSize); + return { items, page, pageSize, total: count }; +} + +export async function getRecordingsForSession(playSessionId: number) { + return db + .select({ id: recordings.id, name: recordings.name }) + .from(recordings) + .where(eq(recordings.sourcePlaySessionId, playSessionId)); +} + +/** Lightweight name-only lookup for page titles - avoids getRecording's event-table join. */ +export async function getRecordingName(id: number): Promise<string | undefined> { + const [row] = await db.select({ name: recordings.name }).from(recordings).where(eq(recordings.id, id)); + return row?.name; +} + export async function getRecording(id: number) { const [recording] = await db.select().from(recordings).where(eq(recordings.id, id)); if (!recording) return undefined; diff --git a/lib/pagination.ts b/lib/pagination.ts new file mode 100644 index 0000000..d0067d4 --- /dev/null +++ b/lib/pagination.ts @@ -0,0 +1,13 @@ +export const PAGE_SIZE = 20; + +export function parsePage(value: string | string[] | undefined): number { + const n = Number(Array.isArray(value) ? value[0] : value); + return Number.isInteger(n) && n > 0 ? n : 1; +} + +export interface Page<T> { + items: T[]; + page: number; + pageSize: number; + total: number; +} diff --git a/package.json b/package.json index 69a2a24..9561c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sexy", - "version": "0.2.1", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev",