11 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 27d0db0f72 Bump to 0.8.2
CI / Build and push image (push) Successful in 1m11s
CI / Static checks (push) Successful in 1m3s
Devices table's editable name field now matches the session
title/description underline style app-wide (hover/focus turn primary,
primary caret, default text color), and Devices moved before Stats in
the nav order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:43:53 +02:00
valknarandClaude Sonnet 5 e436ced95b Move Devices before Stats in the nav order
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:43:05 +02:00
valknarandClaude Sonnet 5 fe017497a2 Unify inline-editable field styling app-wide
Device display name, session title, and session description editors
now share one style: underline is invisible until hover/focus (then
primary), the caret is primary while editing, and text keeps its own
default color at all times instead of shifting on focus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:41:13 +02:00
valknarandClaude Sonnet 5 fc7fd74d17 Bump to 0.8.1
CI / Build and push image (push) Successful in 1m8s
CI / Static checks (push) Successful in 1m10s
Delete button on the session detail view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:31:46 +02:00
valknarandClaude Sonnet 5 8c3d4e4e33 Add a delete button on the session detail view
Same confirm-dialog delete UX as the sessions table, next to Replay in
the header. Deleting redirects back to /sessions since the session no
longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:31:16 +02:00
valknar 581c3c0e6c chore: cleanup 2026-08-31 19:25:51 +02:00
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
15 changed files with 202 additions and 122 deletions
+3 -1
View File
@@ -7,7 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor"; import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor";
import { SessionDescriptionEditor } from "@/components/sessions/SessionDescriptionEditor"; import { SessionDescriptionEditor } from "@/components/sessions/SessionDescriptionEditor";
import { STATUS_VARIANT } from "@/components/sessions/SessionsTable"; import { SessionDeleteButton } from "@/components/sessions/SessionDeleteButton";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions"; import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats"; import { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react"; import { Play } from "lucide-react";
@@ -65,6 +66,7 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
</Link> </Link>
</Button> </Button>
)} )}
<SessionDeleteButton sessionId={detail.session.id} sessionName={detail.session.name} />
</div> </div>
</div> </div>
+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> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const name = await getSessionName(Number(id)); 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 }> }) { export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const name = await getSessionName(Number(id));
return ( return (
<div className="flex flex-col gap-6"> <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)} /> <ReplayPlayerLoader sessionId={Number(id)} />
</div> </div>
); );
+2 -2
View File
@@ -225,7 +225,7 @@ export function ButtplugConsole() {
return ( return (
<div className="flex flex-col gap-6"> <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()} /> <DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
<RecordControls <RecordControls
active={activeSession !== null} active={activeSession !== null}
@@ -241,7 +241,7 @@ export function ButtplugConsole() {
{Object.keys(devices).length === 0 ? ( {Object.keys(devices).length === 0 ? (
<Card className="bp-glass"> <Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground"> <CardContent className="text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s
pairing prompt. pairing prompt.
</CardContent> </CardContent>
+3 -3
View File
@@ -37,9 +37,9 @@ export function DeviceCard({
}: DeviceCardProps) { }: DeviceCardProps) {
return ( return (
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between pb-3"> <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 pb-3">
<span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span> <span className="min-w-0 truncate text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<div className="flex items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
{device.hasBattery && {device.hasBattery &&
(batteryLevel !== null ? ( (batteryLevel !== null ? (
<BatteryIndicator level={batteryLevel} /> <BatteryIndicator level={batteryLevel} />
+1 -1
View File
@@ -15,7 +15,7 @@ interface RecordControlsProps {
export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) { export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) {
if (active) { if (active) {
return ( 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"> <span className="flex items-center gap-2 text-sm font-medium">
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" /> <Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel} Session live · {elapsedLabel}
+6 -7
View File
@@ -3,7 +3,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Check, Loader2 } from "lucide-react"; import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -55,23 +54,23 @@ function DeviceNameCell({ device }: { device: DeviceRow }) {
}, [status]); }, [status]);
return ( return (
<div className="relative w-48"> <div className="flex items-center gap-2">
<Input <input
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
className="h-8 pr-8"
aria-label="Device display name" aria-label="Device display name"
className="max-w-48 min-w-0 border-b-2 border-transparent bg-transparent text-sm font-medium text-foreground caret-primary outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-b-primary focus:border-b-primary [field-sizing:content]"
/> />
<div className="pointer-events-none absolute inset-y-0 right-2 flex w-3.5 items-center justify-center"> <div className="relative flex size-4 shrink-0 items-center justify-center">
<Loader2 <Loader2
className={cn( className={cn(
"absolute size-3.5 animate-spin text-muted-foreground transition-opacity", "absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0", status === "saving" ? "opacity-100" : "opacity-0",
)} )}
/> />
<Check <Check
className={cn( className={cn(
"absolute size-3.5 text-primary transition-opacity", "absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0", status === "saved" ? "opacity-100" : "opacity-0",
)} )}
/> />
+1 -1
View File
@@ -15,8 +15,8 @@ const LINKS = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
{ href: "/control", label: "Control" }, { href: "/control", label: "Control" },
{ href: "/sessions", label: "Sessions" }, { href: "/sessions", label: "Sessions" },
{ href: "/stats", label: "Stats" },
{ href: "/devices", label: "Devices" }, { href: "/devices", label: "Devices" },
{ href: "/stats", label: "Stats" },
]; ];
function isActive(pathname: string, href: string): boolean { function isActive(pathname: string, href: string): boolean {
+22 -16
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; 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 { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel"; import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
@@ -137,18 +137,9 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
return ( return (
<div className="flex flex-col gap-6"> <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 ? ( {!player ? (
<> <>
<p className="text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3">
Connect the devices you want to replay onto, then match them to the session&apos;s
original device slots.
</p>
<div className="flex items-center gap-3">
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} /> <DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
<Button <Button
onClick={async () => { onClick={async () => {
@@ -161,12 +152,28 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
}} }}
disabled={connectedDevices.length === 0 || starting} disabled={connectedDevices.length === 0 || starting}
> >
{starting ? "Starting..." : "Match devices & replay"} {starting ? (
"Starting..."
) : (
<>
<Play className="size-3.5" /> Match & replay
</>
)}
</Button> </Button>
</div> </div>
{connectedDevices.length === 0 && (
<Card className="bp-glass">
<CardContent className="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.
</CardContent>
</Card>
)}
</> </>
) : ( ) : (
<div className="flex flex-col gap-3"> <Card className="bp-glass">
<CardContent className="flex flex-col gap-3">
{replayTargets.length > 0 && ( {replayTargets.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{replayTargets.map((t) => ( {replayTargets.map((t) => (
@@ -184,7 +191,7 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
max={data.session.durationMs} max={data.session.durationMs}
onValueChange={([v]) => player.seek(v)} onValueChange={([v]) => player.seek(v)}
/> />
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<span className="bp-readout text-xs text-muted-foreground"> <span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.session.durationMs)} {formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
</span> </span>
@@ -236,10 +243,9 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
</Button> </Button>
</div> </div>
</div> </div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
<DeviceRemapDialog <DeviceRemapDialog
open={showRemap} open={showRemap}
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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";
export function SessionDeleteButton({ sessionId, sessionName }: { sessionId: number; sessionName: string | null }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
const res = await fetch(`/api/sessions/${sessionId}`, { method: "DELETE" });
setDeleting(false);
if (res.ok) {
toast.success("Session deleted");
router.push("/sessions");
router.refresh();
} else {
toast.error("Could not delete session");
}
}
return (
<>
<Button variant="ghost" size="icon-sm" onClick={() => setOpen(true)} aria-label="Delete session">
<Trash2 className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete session?</DialogTitle>
<DialogDescription>
This permanently deletes{" "}
<span className="font-medium text-foreground">{sessionName ?? `Session #${sessionId}`}</span> and all
of its recorded events. This can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -60,7 +60,7 @@ export function SessionDescriptionEditor({
placeholder="Add a description..." placeholder="Add a description..."
aria-label="Session description" aria-label="Session description"
rows={1} rows={1}
className="max-w-full min-w-0 resize-none border-b-2 border-transparent bg-transparent text-sm text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:border-b-border focus:border-b-foreground/40 focus:text-foreground sm:max-w-xl [field-sizing:content]" className="max-w-full min-w-0 resize-none border-b-2 border-transparent bg-transparent text-sm text-muted-foreground caret-primary outline-none transition-colors placeholder:text-muted-foreground/60 hover:border-b-primary focus:border-b-primary sm:max-w-xl [field-sizing:content]"
/> />
<div className="relative mt-0.5 flex size-4 shrink-0 items-center justify-center"> <div className="relative mt-0.5 flex size-4 shrink-0 items-center justify-center">
<Loader2 <Loader2
+1 -1
View File
@@ -52,7 +52,7 @@ export function SessionTitleEditor({ sessionId, initialName }: { sessionId: numb
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={`Session #${sessionId}`} placeholder={`Session #${sessionId}`}
aria-label="Session name" aria-label="Session name"
className="max-w-full min-w-0 border-b-2 border-transparent bg-transparent font-heading text-2xl font-semibold text-foreground outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-b-border focus:border-b-foreground/40 sm:max-w-xl [field-sizing:content]" className="max-w-full min-w-0 border-b-2 border-transparent bg-transparent font-heading text-2xl font-semibold text-foreground caret-primary outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-b-primary focus:border-b-primary sm:max-w-xl [field-sizing:content]"
/> />
<div className="relative flex size-4 shrink-0 items-center justify-center"> <div className="relative flex size-4 shrink-0 items-center justify-center">
<Loader2 <Loader2
+1 -6
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Play, Trash2 } from "lucide-react"; import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
export interface SessionRow { export interface SessionRow {
id: number; id: number;
@@ -25,12 +26,6 @@ export interface SessionRow {
durationMs: number | null; durationMs: number | null;
} }
export const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
function formatDuration(ms: number | null): string { function formatDuration(ms: number | null): string {
if (ms === null) return "-"; if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000); 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> <CardHeader>
<CardTitle className="text-base">Device usage</CardTitle> <CardTitle className="text-base">Device usage</CardTitle>
</CardHeader> </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> </Card>
); );
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.7.0", "version": "0.8.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",