40 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 48c31388db Bump to 0.9.3
CI / Build and push image (push) Successful in 1m3s
CI / Static checks (push) Successful in 1m6s
- .bp-glass: recolors Card's default ring to the accent token
  (ring-accent) instead of leaving it at foreground/10; border-accent
  is currently a no-op since nothing sets a border-width utility
- Dashboard hero card: tightens vertical padding (py-8 -> py-4)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:32:25 +02:00
valknarandClaude Sonnet 5 68b0e4d0bd Bump to 0.9.2
CI / Build and push image (push) Successful in 1m4s
CI / Static checks (push) Successful in 1m4s
Lightens .bp-glass's shadow to shadow-xs (from shadow-xl). Card's own
default ring is no longer neutralized here, so it shows through on
glass surfaces again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:18:31 +02:00
valknarandClaude Sonnet 5 cf5353dfe4 Bump to 0.9.1
CI / Static checks (push) Successful in 1m7s
CI / Build and push image (push) Successful in 1m5s
Simplifies .bp-glass: drops the border and hover state, and applies
shadow-xl by default. Also zeroes the ring/border Card's own base
styles would otherwise still contribute, so glass surfaces render
with no border/ring/outline at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:11:12 +02:00
valknarandClaude Sonnet 5 44755c6e85 Bump to 0.9.0
CI / Static checks (push) Successful in 1m40s
CI / Build and push image (push) Successful in 1m8s
Adds a shadcn Alert for the "no devices connected" hints on Control
and Replay, then reworks the navigation: an animated hamburger/close
toggle, a redesigned mobile drawer (brand header, icon nav rows,
theme/logout moved into the drawer footer), and a desktop tab strip
restyled to match the drawer's active-tab treatment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:37:02 +02:00
valknarandClaude Sonnet 5 494e963fa3 Streamline the desktop nav with the mobile drawer's tab style
Desktop tabs drop the uppercase/underline treatment for plain-case
labels with a per-icon primary tint, and the active tab lights up
with a bg-primary/10 fill (no border) instead of an underline -
matching the mobile drawer's active-row treatment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:36:02 +02:00
valknarandClaude Sonnet 5 f8f687af4e Polish the mobile menu
- Header: replaces the plain "Menu" title with the brand mark
  (animated, matching the dashboard hero) + wordmark and a short
  description line
- Nav rows get icons and an active-state background/icon tint instead
  of reusing the desktop tab strip's underline
- Desktop tabs also gain matching icons
- Theme toggle and Log out move from the cramped mobile header into
  labeled rows at the bottom of the drawer; desktop keeps them in the
  header
- The header hamburger button is now a self-contained, controlled
  toggle whose bars morph into an X on open/close, sized to match the
  drawer's built-in close button

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:23:38 +02:00
valknarandClaude Sonnet 5 69e4fb286c Give the "no devices connected" alerts a title
Splits the placeholder text into an AlertTitle ("No devices connected
yet") and an AlertDescription for the rest, in both ButtplugConsole
and ReplayPlayer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:06:28 +02:00
valknarandClaude Sonnet 5 ea320548b6 Use shadcn Alert for the "no devices connected" hints
Replaces the ad-hoc bp-glass Card used for the "No devices connected
yet." placeholder in ButtplugConsole and ReplayPlayer with the shadcn
Alert component (newly installed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:02:32 +02:00
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
valknarandClaude Sonnet 5 dde6adb089 Bump to 0.7.0
CI / Static checks (push) Successful in 1m38s
CI / Build and push image (push) Successful in 1m7s
Inline-editable session title/description (autosave, no Save button,
same pattern as device display names), status badge on the session
detail page, dashboard's Recent sessions restyled as a real table,
stats page tab switcher removed, and various card/nav polish.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:23:30 +02:00
valknarandClaude Sonnet 5 b0394d5c9d Size the description input to its content, like the title
Dropping flex-1 stops the textarea (and its underline) from stretching
to fill the row width regardless of text length - now it hugs the
actual content the same way the title input does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:21:40 +02:00
valknarandClaude Sonnet 5 5c92b416c0 Tone down the focus underline to just a step past hover
focus:border-b-muted-foreground was a big jump from the faint hover
border tone. Use foreground at 40% opacity instead - a bit more
present than hover, not a solid gray line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:19:07 +02:00
valknarandClaude Sonnet 5 2a52b5db9b Use a gray focus underline for the title/description inputs
focus:border-b-muted-foreground instead of the primary accent color -
matches the existing hover state's neutral tone rather than switching
to a bright accent on focus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:17:23 +02:00
valknarandClaude Sonnet 5 39539b6f94 Fix title/description overflow by restoring stretch on mobile
items-start on the stacked mobile header removed the default stretch,
so the min-w-0/flex-1 title column sized to its own content instead of
the row's width - leaving max-w-full on the field-sizing:content
inputs with no definite containing-block width to clamp against, so
they (and the whole row) could overflow horizontally. Dropping
items-start restores the default stretch; the right-hand badge/
duration/replay group already had its own self-end override, so it
still sits at the right edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:15:48 +02:00
valknarandClaude Sonnet 5 a0211f90e4 Cap title and description editor width
Both used field-sizing:content with only max-w-full, which doesn't
reliably clamp a long single-line value and could push the header row
wider than its container. Add an explicit sm:max-w-xl ceiling on both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:11:27 +02:00
valknarandClaude Sonnet 5 26897e1621 Right-align the status/duration/replay row on mobile
self-end (reverting to self-auto at sm+) so the stacked row sits
against the right edge under the title instead of the left, matching
where it sits in the desktop row layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:10:00 +02:00
valknarandClaude Sonnet 5 1bd38f8194 Stack session header below description on mobile
The title/status/replay row was a fixed flex-row that squeezed the
badge, duration, and Replay button next to a long title on narrow
screens. Now stacks (flex-col) below sm and goes side-by-side from sm
up, matching how the rest of the app breaks to mobile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:09:35 +02:00
valknarandClaude Sonnet 5 ffc63f0b03 Make the session description always visible and inline-editable
New SessionDescriptionEditor renders even when empty (with an "Add a
description..." placeholder) and autosaves on typing - same debounced,
no-Save-button pattern as the title and device name editors. Unlike
the name field, an empty description is a valid saved state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:08:45 +02:00
valknarandClaude Sonnet 5 a94f2f33b6 Move status badge and duration next to the Replay button
They now sit inline in the right-hand column alongside Replay instead
of prefixing the left column's meta line, which now only shows the
replay-lineage/play-count info when there is any.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:07:18 +02:00
valknarandClaude Sonnet 5 e9d5bb37d2 Show session status as a badge on the detail page
Reuses SessionsTable's STATUS_VARIANT mapping (now exported) so the
detail page's status matches the table's badge instead of plain text.
Also drops the now-stale px-1.5 alignment padding on the meta lines
now that the title input carries no horizontal padding of its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:03:00 +02:00
valknarandClaude Sonnet 5 354c30d641 Underline the session title input instead of a full border
Drops the boxed border-b-2/rounded treatment for just a bottom
border - transparent at rest, muted on hover, primary-colored on
focus - which reads as inline editing rather than a form field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:55:07 +02:00
valknarandClaude Sonnet 5 7bf4b3d4bf Fix session title input's border offset and width
The -mx-2 trick pushed the hover/focus border noticeably left of the
text, and flex-1 stretched the input across the whole header row.
Tighten the offset to px-1.5/-ml-1.5 and size the input to its content
via field-sizing:content instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:52:44 +02:00
valknarandClaude Sonnet 5 884ced7a47 Make the session title editable inline on the detail page
New SessionTitleEditor swaps the static <h1> for a heading-styled input
that autosaves on typing (600ms debounce, spinner-then-checkmark) - the
same click-in-place, no-Save-button pattern as the device display name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:49:34 +02:00
valknarandClaude Sonnet 5 005d729119 Autosave device display name instead of an explicit Save button
Typing in the name field now debounces (600ms) and saves in the
background, with an inline spinner-then-checkmark instead of a button
click - one less step, and no risk of leaving an edited name unsaved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:46:53 +02:00
valknarandClaude Sonnet 5 fa04cb32b4 Streamline card titles to a single color
Every CardTitle now uses the default foreground color; only size marks
the hierarchy (text-sm for stat-tile labels, text-base for section
titles, text-2xl for the login wordmark) instead of some titles being
muted-gray and others white.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:42:41 +02:00
valknarandClaude Sonnet 5 c778ab3cf2 Add a title to the device usage table card on Stats
Matches the "History"/"Known devices" card-title pattern used on the
Sessions and Devices pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:41:05 +02:00
valknarandClaude Sonnet 5 7643e9d509 Style dashboard's Recent sessions as a real table
Reuse SessionsTable (same component as the Sessions page) instead of a
bespoke flex/hairline list, so status badges, replay/delete actions,
and column layout match the rest of the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:39:43 +02:00
valknarandClaude Sonnet 5 d5a8cb81cf Highlight nav link as active for sub-routes too
/sessions/16 (and any other nested route) now underlines its parent
nav link instead of only an exact pathname match, which previously
left every detail/replay page with no active tab at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:37:25 +02:00
valknarandClaude Sonnet 5 c4ed849d37 Remove stats tab switcher, drop duration-per-device, show devices inline
Sessions and Devices no longer live behind a Tabs switcher on the stats
page - both sections render directly, one after the other. Also drops
the "Duration per device" list from the sessions summary cards since
DeviceUsageTable already shows per-device active time in more detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:33:47 +02:00
valknarandClaude Sonnet 5 e459ccd0ed Polish replays stat into the summary card grid
The replay count was a lone plain-text bar below the three stat cards,
and read oddly as "Replayed 0x" when nothing had been replayed yet.
Fold it into the same 4-tile grid with the same styling, and show "-"
at zero instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:31:31 +02:00
21 changed files with 716 additions and 306 deletions
+8 -18
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark";
import { SessionsTable } from "@/components/sessions/SessionsTable";
import { listSessions } from "@/lib/db/queries/sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats";
@@ -17,7 +18,7 @@ export default async function DashboardPage() {
return (
<div className="flex flex-col gap-8">
<Card className="bp-glass overflow-hidden">
<CardContent className="flex flex-col items-start gap-4 py-8">
<CardContent className="flex flex-col items-start gap-4 py-4">
<BrandMark size={48} animated className="rounded-[9px]" />
<h1 className="font-heading text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground">
@@ -33,13 +34,13 @@ export default async function DashboardPage() {
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
<CardTitle className="text-sm">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle>
<CardTitle className="text-sm">Total play time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h
@@ -47,7 +48,7 @@ export default async function DashboardPage() {
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Replays</CardTitle>
<CardTitle className="text-sm">Replays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent>
</Card>
@@ -55,21 +56,10 @@ export default async function DashboardPage() {
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent sessions</CardTitle>
<CardTitle className="text-base">Recent sessions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentSessions.map((s, i) => (
<Link key={s.id} href={`/sessions/${s.id}`} className="group">
{i > 0 && <div className="bp-hairline" />}
<div className="flex items-center justify-between px-1 py-2 text-sm">
<span className="group-hover:text-primary">{s.name ?? `Session #${s.id}`}</span>
<span className="bp-readout text-xs text-muted-foreground">
{new Date(s.startedAt).toLocaleDateString()}
</span>
</div>
</Link>
))}
<CardContent className="flex flex-col gap-3">
<SessionsTable sessions={recentSessions} />
</CardContent>
</Card>
</div>
+34 -25
View File
@@ -1,9 +1,14 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { SessionDeleteButton } from "@/components/sessions/SessionDeleteButton";
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";
@@ -32,33 +37,37 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
<p className="text-sm text-muted-foreground">
{detail.session.status} · {formatDuration(detail.session.durationMs)}
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && (
<> · replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</>
)}
{detail.session.playCount > 0 && (
<>
{" "}
· replayed {detail.session.playCount}×
{detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`}
</>
)}
</p>
{detail.session.description && (
<p className="mt-1 text-sm text-muted-foreground">{detail.session.description}</p>
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SessionTitleEditor sessionId={detail.session.id} initialName={detail.session.name} />
{(detail.session.kind === "replay" || detail.session.playCount > 0) && (
<p className="mt-1 text-sm text-muted-foreground">
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && (
<>replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</>
)}
{detail.session.playCount > 0 && (
<>
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && " · "}
replayed {detail.session.playCount}×
{detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`}
</>
)}
</p>
)}
<SessionDescriptionEditor sessionId={detail.session.id} initialDescription={detail.session.description} />
</div>
<div className="flex shrink-0 items-center gap-3 self-end sm:self-auto">
<Badge variant={STATUS_VARIANT[detail.session.status]}>{detail.session.status}</Badge>
<span className="bp-readout text-sm text-muted-foreground">{formatDuration(detail.session.durationMs)}</span>
{canReplay && (
<Button asChild>
<Link href={`/sessions/${detail.session.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
)}
<SessionDeleteButton sessionId={detail.session.id} sessionName={detail.session.name} />
</div>
{canReplay && (
<Button asChild>
<Link href={`/sessions/${detail.session.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
)}
</div>
<Card className="bp-glass">
+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>
);
+2 -13
View File
@@ -1,5 +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";
import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats";
@@ -24,18 +23,8 @@ export default async function StatsPage() {
<p className="text-sm text-muted-foreground">Usage across sessions and devices.</p>
</div>
<Tabs defaultValue="sessions">
<TabsList className="bp-glass">
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger>
</TabsList>
<TabsContent value="sessions" className="pt-4">
<SessionsSummaryCards summary={sessionsSummary} />
</TabsContent>
<TabsContent value="devices" className="pt-4">
<DeviceUsageTable devices={devices} />
</TabsContent>
</Tabs>
<SessionsSummaryCards summary={sessionsSummary} />
<DeviceUsageTable devices={devices} />
</div>
);
}
+6 -14
View File
@@ -181,23 +181,15 @@ body {
}
/* Console-module surface: translucent + blurred so the animated background
sweep shines through, with a hairline border and soft inset top highlight. */
sweep shines through. Card's own default ring (components/ui/card.tsx:
`ring-1 ring-foreground/10`) still shows - ring-accent recolors it to the
accent token instead of resetting it. border-accent is currently a no-op
(neither Card nor this rule sets a border-width utility to give it a
border to color). */
.bp-glass {
@apply shadow-xs ring-accent border-accent;
background: color-mix(in srgb, var(--card) 55%, transparent);
backdrop-filter: blur(20px) saturate(150%);
border: 1px solid var(--border);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
.bp-glass:hover {
background: color-mix(in srgb, var(--card) 62%, transparent);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--foreground) 10%, transparent),
0 10px 30px -14px rgba(0, 0, 0, 0.45);
}
/* Illuminated indicator dot - reused on connection status and per-actuator
+10 -7
View File
@@ -22,6 +22,8 @@ import { DeviceCard } from "./DeviceCard";
import { RecordControls } from "./RecordControls";
import { NameSessionDialog } from "./NameSessionDialog";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info } from "lucide-react";
interface ActiveSession {
id: number;
@@ -225,7 +227,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}
@@ -240,12 +242,13 @@ export function ButtplugConsole() {
{storeError && <p className="text-sm text-destructive">{storeError}</p>}
{Object.keys(devices).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 select one from your browser&apos;s
pairing prompt.
</CardContent>
</Card>
<Alert className="bp-glass">
<Info />
<AlertTitle>No devices connected yet</AlertTitle>
<AlertDescription>
Scan to discover nearby toys, then select one from your browser&apos;s pairing prompt.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{Object.values(devices).map((device) => (
+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}
+58 -25
View File
@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export interface DeviceRow {
id: number;
@@ -14,34 +14,67 @@ export interface DeviceRow {
lastConnectedAt: number | null;
}
const AUTOSAVE_DELAY_MS = 600;
function DeviceNameCell({ device }: { device: DeviceRow }) {
const router = useRouter();
const [value, setValue] = useState(device.displayName ?? device.bleName);
const [saving, setSaving] = useState(false);
const initial = device.displayName ?? device.bleName;
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
async function handleSave() {
if (value.trim().length === 0) return;
setSaving(true);
const res = await fetch(`/api/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: value.trim() }),
});
setSaving(false);
if (res.ok) {
toast.success("Renamed");
router.refresh();
} else {
toast.error("Could not rename device");
}
}
useEffect(() => {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: trimmed }),
});
if (res.ok) {
lastSaved.current = trimmed;
setStatus("saved");
router.refresh();
} else {
setStatus("idle");
toast.error("Could not rename device");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, device.id, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<div className="flex items-center gap-2">
<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>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
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="relative flex size-4 shrink-0 items-center justify-center">
<Loader2
className={cn(
"absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
);
}
+139 -40
View File
@@ -4,44 +4,106 @@ import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { Menu, Moon, Sun, LogOut } from "lucide-react";
import { Moon, Sun, LogOut, Gauge, SlidersHorizontal, ListMusic, Bluetooth, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { ConnectionStatus } from "./ConnectionStatus";
import { BrandMark } from "./BrandMark";
import { cn } from "@/lib/utils";
const LINKS = [
{ href: "/", label: "Dashboard" },
{ href: "/control", label: "Control" },
{ href: "/sessions", label: "Sessions" },
{ href: "/stats", label: "Stats" },
{ href: "/devices", label: "Devices" },
{ href: "/", label: "Dashboard", icon: Gauge },
{ href: "/control", label: "Control", icon: SlidersHorizontal },
{ href: "/sessions", label: "Sessions", icon: ListMusic },
{ href: "/devices", label: "Devices", icon: Bluetooth },
{ href: "/stats", label: "Stats", icon: BarChart3 },
];
function isActive(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
}
/** Desktop tab strip - borderless chips; the active tab lights up with a
* primary-tinted fill, matching the drawer rows' active treatment. */
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<>
{LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={onNavigate}
className={cn(
"border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors",
pathname === link.href
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{link.label}
</Link>
))}
{LINKS.map((link) => {
const active = isActive(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors",
active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<link.icon className={cn("size-3.5", active && "text-primary")} />
{link.label}
</Link>
);
})}
</>
);
}
/** Drawer rows for the mobile menu - icon + label, styled as tappable
* console rows rather than the desktop tab strip's underline treatment. */
function MobileNavLinks({ onNavigate }: { onNavigate: () => void }) {
const pathname = usePathname();
return (
<>
{LINKS.map((link) => {
const active = isActive(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-sm font-medium transition-colors",
active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<link.icon className={cn("size-4 shrink-0", active && "text-primary")} />
{link.label}
</Link>
);
})}
</>
);
}
/** Hamburger that morphs into an X - the top/bottom bars rotate into place
* and the middle bar collapses, rather than a straight icon swap. */
function MenuToggleIcon({ open }: { open: boolean }) {
return (
<span className="relative flex size-4 shrink-0 flex-col items-center justify-center" aria-hidden>
<span
className={cn(
"absolute h-0.5 w-4 rounded-full bg-current transition-transform duration-300 ease-out motion-reduce:transition-none",
open ? "translate-y-0 rotate-45" : "-translate-y-[5px] rotate-0",
)}
/>
<span
className={cn(
"absolute h-0.5 w-4 rounded-full bg-current transition-all duration-200 ease-out motion-reduce:transition-none",
open ? "scale-x-0 opacity-0" : "scale-x-100 opacity-100",
)}
/>
<span
className={cn(
"absolute h-0.5 w-4 rounded-full bg-current transition-transform duration-300 ease-out motion-reduce:transition-none",
open ? "translate-y-0 -rotate-45" : "translate-y-[5px] rotate-0",
)}
/>
</span>
);
}
export function NavBar() {
const { theme, setTheme } = useTheme();
const router = useRouter();
@@ -61,36 +123,73 @@ export function NavBar() {
<span className="font-heading text-lg font-semibold">Sexy</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
<nav className="hidden items-center gap-1.5 md:flex">
<NavLinks />
</nav>
<div className="ml-auto flex items-center gap-2">
<ConnectionStatus />
<div className="hidden items-center gap-2 md:flex">
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="hidden size-4 dark:block" />
<Moon className="block size-4 dark:hidden" />
</Button>
<Button variant="ghost" size="icon" aria-label="Log out" onClick={handleLogout}>
<LogOut className="size-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
size="icon-sm"
className="md:hidden"
aria-label={menuOpen ? "Close menu" : "Open menu"}
onClick={() => setMenuOpen((open) => !open)}
>
<Sun className="hidden size-4 dark:block" />
<Moon className="block size-4 dark:hidden" />
</Button>
<Button variant="ghost" size="icon" aria-label="Log out" onClick={handleLogout}>
<LogOut className="size-4" />
<MenuToggleIcon open={menuOpen} />
</Button>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
<Menu className="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64">
<SheetTitle className="px-4 pt-4">Menu</SheetTitle>
<nav className="flex flex-col gap-1 p-4">
<NavLinks onNavigate={() => setMenuOpen(false)} />
<SheetContent side="right" className="w-72 gap-0 p-0">
<div className="flex items-center gap-2 px-4 pt-4 pb-3">
<BrandMark size={24} animated />
<SheetTitle className="font-heading text-lg font-semibold">Sexy</SheetTitle>
</div>
<SheetDescription className="px-4 pb-3 text-xs">
Jump to another page or manage your session.
</SheetDescription>
<div className="bp-hairline" />
<nav className="flex flex-1 flex-col gap-1 p-3">
<MobileNavLinks onNavigate={() => setMenuOpen(false)} />
</nav>
<div className="bp-hairline" />
<div className="flex flex-col gap-1 p-3">
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex items-center gap-3 rounded-lg px-3 py-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Sun className="hidden size-4 dark:block" />
<Moon className="block size-4 dark:hidden" />
{theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
</button>
<button
type="button"
onClick={() => void handleLogout()}
className="flex items-center gap-3 rounded-lg px-3 py-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<LogOut className="size-4" />
Log out
</button>
</div>
</SheetContent>
</Sheet>
</div>
+105 -97
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";
@@ -11,7 +11,8 @@ import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopS
import { SessionPlayer, type SessionEventRow } from "@/lib/buttplug/player";
import { useButtplugStore } from "@/lib/buttplug/store";
import type { ActuatorInfo, ReplayDeviceSlot } from "@/lib/buttplug/types";
import { Pause, Play } from "lucide-react";
import { Info, Pause, Play } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface ReplayDataResponse {
session: {
@@ -137,109 +138,116 @@ 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
original device slots.
</p>
<div className="flex items-center gap-3">
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
{!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 && (
<Alert className="bp-glass">
<Info />
<AlertTitle>No devices connected yet</AlertTitle>
<AlertDescription>
Scan to discover nearby toys, then match them to this session&apos;s original device slots.
</AlertDescription>
</Alert>
)}
</>
) : (
<Card className="bp-glass">
<CardContent 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 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}
@@ -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>
</>
);
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const AUTOSAVE_DELAY_MS = 600;
export function SessionDescriptionEditor({
sessionId,
initialDescription,
}: {
sessionId: number;
initialDescription: string | null;
}) {
const router = useRouter();
const initial = initialDescription ?? "";
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
useEffect(() => {
// Unlike the session name, an empty description is a valid, saveable
// state (it just means "no description"), so no non-empty guard here.
if (value === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: value }),
});
if (res.ok) {
lastSaved.current = value;
setStatus("saved");
router.refresh();
} else {
setStatus("idle");
toast.error("Could not save description");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, sessionId, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<div className="mt-1 flex items-start gap-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Add a description..."
aria-label="Session description"
rows={1}
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">
<Loader2
className={cn(
"absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const AUTOSAVE_DELAY_MS = 600;
export function SessionTitleEditor({ sessionId, initialName }: { sessionId: number; initialName: string | null }) {
const router = useRouter();
const initial = initialName ?? "";
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
useEffect(() => {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
lastSaved.current = trimmed;
setStatus("saved");
router.refresh();
} else {
setStatus("idle");
toast.error("Could not rename session");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, sessionId, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<div className="flex items-center gap-2">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Session #${sessionId}`}
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 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">
<Loader2
className={cn(
"absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
);
}
+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;
}
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;
+8 -2
View File
@@ -1,4 +1,4 @@
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export interface DeviceUsageRow {
@@ -15,13 +15,19 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
if (devices.length === 0) {
return (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent>
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">No device activity yet.</CardContent>
</Card>
);
}
return (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
+29 -40
View File
@@ -5,56 +5,45 @@ export interface SessionsSummary {
totalDurationMs: number;
avgDurationMs: number;
totalReplays: number;
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
}
function formatHours(ms: number): string {
return `${(ms / 3_600_000).toFixed(1)}h`;
}
function formatReplays(count: number): string {
return count === 0 ? "-" : `${count}×`;
}
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
return (
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m
</CardContent>
</Card>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card className="bp-glass">
<CardContent className="py-3 text-sm text-muted-foreground">Replayed {summary.totalReplays}×</CardContent>
<CardHeader>
<CardTitle className="text-sm">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Total time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Avg session length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Replays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatReplays(summary.totalReplays)}</CardContent>
</Card>
{summary.durationPerDevice.length > 0 && (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Duration per device</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{summary.durationPerDevice.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between text-sm">
<span>{d.displayName ?? d.bleName}</span>
<span className="text-muted-foreground">{formatHours(d.totalActiveMs)}</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+1 -12
View File
@@ -15,18 +15,7 @@ export async function getSessionsSummary() {
.from(sessions)
.where(eq(sessions.status, "completed"));
const durationPerDevice = await db
.select({
deviceId: devices.id,
displayName: devices.displayName,
bleName: devices.bleName,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.groupBy(devices.id);
return { ...totals, durationPerDevice };
return totals;
}
export async function getSessionTimeline(sessionId: number, bucketMs = 1000) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sexy",
"version": "0.6.0",
"version": "0.9.3",
"private": true,
"scripts": {
"dev": "next dev",