5 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 afa6e8d473 Bump to 0.8.0
CI / Static checks (push) Successful in 1m40s
CI / Build and push image (push) Successful in 1m8s
Control view button layout fixes (buttons sit next to each other, wrap
responsively on narrow screens), Replay view streamlined to match the
Control view (title includes the session name, buttons below the
header, matching "no devices connected" hint card, Play icon on
"Match & replay"), and the session-status Badge color mismatch between
the sessions overview and detail view fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:33:49 +02:00
valknarandClaude Sonnet 5 1bad1f4e33 Streamline the Replay view with the Control view
Page title is now "{session name} - Replay" (matching generateMetadata)
instead of a bare "Replay" heading with the name duplicated inside a
Card title. Dropped that redundant outer Card - the pre-connection
scan/match buttons now sit in a flat row below the header, same as
Control's scan/record row, with the same "no devices connected" hint
card shown underneath when nothing's connected yet. Playback controls
once a replay starts still live in a card, matching DeviceCard's style.
Also gave "Match & replay" a Play icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:31:57 +02:00
valknarandClaude Sonnet 5 c06f119dcf Fix Control view button layout and responsiveness
The scan/record button row used justify-between, spreading the two
button groups to opposite ends instead of sitting next to each other.
Also made the record-controls row and each device card's header wrap
instead of overflowing on narrow screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:31:51 +02:00
valknar 20433a6749 chore: cleanup 2026-08-31 17:20:22 +02:00
valknarandClaude Sonnet 5 1d88ab96c8 Move STATUS_VARIANT out of the "use client" SessionsTable module
The session detail page (a server component) was importing STATUS_VARIANT
from SessionsTable.tsx, a "use client" file. Next.js compiles client
modules separately for the server and client bundles, and pulling a plain
value (not a component) across that boundary let the two compiler caches
drift out of sync in dev, so the same session status could render a
different Badge color depending on which page rendered it. Moved the
constant into its own plain module that both files import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:19:24 +02:00
10 changed files with 127 additions and 111 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor";
import { SessionDescriptionEditor } from "@/components/sessions/SessionDescriptionEditor";
import { STATUS_VARIANT } from "@/components/sessions/SessionsTable";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react";
+9 -2
View File
@@ -5,14 +5,21 @@ import { getSessionName } from "@/lib/db/queries/sessions";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const name = await getSessionName(Number(id));
return { title: name ? `Replay ${name}` : "Replay" };
return { title: name ? `${name} - Replay` : "Replay" };
}
export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const name = await getSessionName(Number(id));
return (
<div className="flex flex-col gap-6">
<h1 className="font-heading text-2xl font-semibold">Replay</h1>
<div>
<h1 className="font-heading text-2xl font-semibold">{name ?? `Session #${id}`} - Replay</h1>
<p className="text-sm text-muted-foreground">
Connect the devices you want to replay onto, then match them to this session&apos;s original device
slots and play its recorded intensity back live over Web Bluetooth.
</p>
</div>
<ReplayPlayerLoader sessionId={Number(id)} />
</div>
);
+1 -1
View File
@@ -225,7 +225,7 @@ export function ButtplugConsole() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4">
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
<RecordControls
active={activeSession !== null}
+3 -3
View File
@@ -37,9 +37,9 @@ export function DeviceCard({
}: DeviceCardProps) {
return (
<Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<div className="flex items-center gap-3">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 pb-3">
<span className="min-w-0 truncate text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<div className="flex shrink-0 items-center gap-3">
{device.hasBattery &&
(batteryLevel !== null ? (
<BatteryIndicator level={batteryLevel} />
+1 -1
View File
@@ -15,7 +15,7 @@ interface RecordControlsProps {
export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) {
if (active) {
return (
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="flex items-center gap-2 text-sm font-medium">
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel}
+101 -95
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
@@ -137,109 +137,115 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
return (
<div className="flex flex-col gap-6">
<Card className="bp-glass">
<CardHeader>
<CardTitle>{data.session.name ?? `Session #${data.session.id}`}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{!player ? (
<>
<p className="text-sm text-muted-foreground">
Connect the devices you want to replay onto, then match them to the session&apos;s
{!player ? (
<>
<div className="flex flex-wrap items-center gap-3">
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
<Button
onClick={async () => {
try {
await getButtplugClientHandle();
setShowRemap(true);
} catch {
toast.error("Could not connect to Buttplug client");
}
}}
disabled={connectedDevices.length === 0 || starting}
>
{starting ? (
"Starting..."
) : (
<>
<Play className="size-3.5" /> Match & replay
</>
)}
</Button>
</div>
{connectedDevices.length === 0 && (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then match them to this session&apos;s
original device slots.
</p>
<div className="flex items-center gap-3">
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
</CardContent>
</Card>
)}
</>
) : (
<Card className="bp-glass">
<CardContent className="flex flex-col gap-3 py-6">
{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.session.durationMs}
onValueChange={([v]) => player.seek(v)}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
</span>
<div className="flex items-center gap-2">
<Button
size="icon"
variant="outline"
onClick={async () => {
try {
await getButtplugClientHandle();
setShowRemap(true);
} catch {
toast.error("Could not connect to Buttplug client");
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();
}}
disabled={connectedDevices.length === 0 || starting}
aria-label={playing ? "Pause" : "Play"}
>
{starting ? "Starting..." : "Match devices & replay"}
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={async () => {
player.stop();
setPlaying(false);
setElapsedMs(0);
// 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([]);
}}
>
Stop
</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.session.durationMs}
onValueChange={([v]) => player.seek(v)}
/>
<div className="flex items-center justify-between">
<span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
</span>
<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);
setElapsedMs(0);
// 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([]);
}}
>
Stop
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)}
<DeviceRemapDialog
open={showRemap}
+1 -6
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
export interface SessionRow {
id: number;
@@ -25,12 +26,6 @@ export interface SessionRow {
durationMs: number | null;
}
export const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
function formatDuration(ms: number | null): string {
if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000);
+8
View File
@@ -0,0 +1,8 @@
// Plain data, deliberately kept out of SessionsTable.tsx (a "use client"
// module) so server components (e.g. the session detail page) can import it
// without pulling a value across the client/server compilation boundary.
export const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
+1 -1
View File
@@ -18,7 +18,7 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent>
<CardContent className="text-sm text-muted-foreground">No device activity yet.</CardContent>
</Card>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sexy",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"scripts": {
"dev": "next dev",