Fix device/replay/session UX issues, add pagination, bump to 0.3.0
CI / Static checks (push) Successful in 1m7s
CI / Build and push image (push) Successful in 1m2s

- 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 <title>s including
  dynamic titles for recording/session detail and replay pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:26:18 +02:00
co-authored by Claude Sonnet 5
parent f97ed6da04
commit 5484d3cefe
28 changed files with 585 additions and 174 deletions
+3
View File
@@ -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">
+14 -4
View File
@@ -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>
+8 -1
View File
@@ -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")}`;
@@ -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;
+14 -4
View File
@@ -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>
+8 -1
View File
@@ -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);
+14 -4
View File
@@ -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>
+2
View File
@@ -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([
+6 -3
View File
@@ -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 },
);
}
+19 -2
View File
@@ -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 {
+1 -1
View File
@@ -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",
};
+4 -1
View File
@@ -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>
+2 -2
View File
@@ -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",