41 Commits
Author SHA1 Message Date
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
valknarandClaude Sonnet 5 401b9b5033 Remove recordings feature, replay sessions directly, bump to 0.6.0
CI / Build and push image (push) Successful in 1m41s
CI / Static checks (push) Successful in 2m12s
Recordings were just a thin named pointer over an already-captured
session's events, so the whole separate feature (recordings table, API
routes, pages, UI) is gone: any completed session can now be named and
replayed directly. Replaying no longer creates a session or duplicates
events of its own - it just bumps the source session's playCount/lastPlayedAt.

Also renames play_sessions/playSession(s) to sessions/session(s) throughout
the schema, queries, API routes, and UI for consistency, and updates the
README to match the new flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:38:51 +02:00
valknarandClaude Sonnet 5 2a3c4ff1f2 Add site footer and switch display font to Sora, bump to 0.5.0
CI / Static checks (push) Successful in 1m7s
CI / Build and push image (push) Successful in 1m6s
Adds a Footer component (credit line, repo/buttplug.io links) below main
content on all app pages, with the layout shell now flex-column so it
sticks to the bottom on short pages. Also swaps the display font from
Space Grotesk to Sora (after trying Fraunces, a serif, which didn't fit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 19:02:09 +02:00
valknarandClaude Sonnet 5 c46adf788f Add animated glass background, breadcrumbs, and brand mark polish
Cards now sit on an animated signature-gradient background and use a
translucent, blurred bp-glass surface so it shines through; extended
bp-glass to every stat surface on the Stats page and to its tab switcher.
Adds a Breadcrumbs component below the header on all app pages.

Also: drop the gradient text fill from the wordmark/heading, animate the
BrandMark's heart and curve on the dashboard hero instead of a plain pulse
ring, recenter the heart/curve artwork in icon.svg and BrandMark, close the
mobile nav menu on link click, remove the redundant "back to recordings"
button, and match the "Start session"/"Scan for devices" button sizes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 18:47:30 +02:00
63 changed files with 2764 additions and 1474 deletions
+28 -21
View File
@@ -2,9 +2,10 @@
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the
[Buttplug](https://buttplug.io) protocol - the same open-source protocol behind Intiface Central and most [Buttplug](https://buttplug.io) protocol - the same open-source protocol behind Intiface Central and most
other legitimate Bluetooth toy-control apps. Record a live control session, save it as a named recording, other legitimate Bluetooth toy-control apps. Every live control session is recorded automatically, and any
and replay it later against a (possibly different) set of connected devices. Track usage in a stats completed session can be replayed later directly, against a (possibly different) set of connected devices -
dashboard. All gated behind a single shared secret, all data kept in a local SQLite file. no separate "save as recording" step. Track usage in a stats dashboard. All gated behind a single shared
secret, all data kept in a local SQLite file.
## Architecture, in words ## Architecture, in words
@@ -16,14 +17,14 @@ Bluetooth hardware at all. So:
as an *embedded* connector - a full Buttplug server compiled to WebAssembly, talking to devices directly as an *embedded* connector - a full Buttplug server compiled to WebAssembly, talking to devices directly
over Web Bluetooth. There's no separate Intiface Engine or Buttplug Server process to run. over Web Bluetooth. There's no separate Intiface Engine or Buttplug Server process to run.
- **The Next.js server is never in the real-time device-control loop.** It only handles the login gate, - **The Next.js server is never in the real-time device-control loop.** It only handles the login gate,
persists recordings and session telemetry to SQLite, serves the stats aggregation endpoints, and serves persists session telemetry to SQLite, serves the stats aggregation endpoints, and serves a session's
recordings back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick. events back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick.
``` ```
Browser Server (Next.js) Browser Server (Next.js)
┌─────────────────────────┐ ┌───────────────────────┐ ┌─────────────────────────┐ ┌───────────────────────┐
│ buttplug-wasm (embedded)│ │ auth / API routes │ │ buttplug-wasm (embedded)│ │ auth / API routes │
│ ↕ Web Bluetooth │ HTTPS │ recordings + sessions │ ↕ Web Bluetooth │ HTTPS │ sessions
│ BLE devices │ ───────► │ stats aggregation │ │ BLE devices │ ───────► │ stats aggregation │
│ live control / replay │ (batch) │ SQLite (better-sqlite3)│ │ live control / replay │ (batch) │ SQLite (better-sqlite3)│
└─────────────────────────┘ └───────────────────────┘ └─────────────────────────┘ └───────────────────────┘
@@ -42,12 +43,12 @@ Bluetooth hardware at all. So:
- Scan for and connect to multiple Bluetooth LE devices at once - Scan for and connect to multiple Bluetooth LE devices at once
- Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI - Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI
- Start/end sessions; save a completed session as a named, replayable recording - Start/end sessions; name one so it's easy to find later
- Replay a recording against a different set of connected devices, with a device-remap step (BLE exposes no - Replay any completed session directly against a different set of connected devices, with a device-remap
stable device id across sessions, so recorded devices are matched to live ones by name, with manual step (BLE exposes no stable device id across sessions, so the session's devices are matched to live ones
override when needed) by name, with manual override when needed) - replaying doesn't create a new session or duplicate events,
- Stats dashboard: session summaries, per-session intensity timelines, per-device usage, recording-library it just bumps the source session's play count
stats (play counts, average length) - Stats dashboard: session summaries, per-session intensity timelines, per-device usage, replay counts
- Single shared-secret login, no user accounts - Single shared-secret login, no user accounts
- SQLite storage, Docker Compose deployment, Gitea Actions CI - SQLite storage, Docker Compose deployment, Gitea Actions CI
@@ -87,9 +88,15 @@ Compose-only (read by `docker-compose.yml` for `${...}` substitution, not by the
## Database ## Database
Schema lives in `lib/db/schema.ts`, migrations in `drizzle/` (generate new ones with `pnpm db:generate` Schema lives in `lib/db/schema.ts`, migrations in `drizzle/` (generate new ones with `pnpm db:generate`
after a schema change, and commit the generated SQL). A recording is a thin pointer over an already-captured after a schema change, and commit the generated SQL). Every live command is always persisted to
session's events, not a separate capture pipeline - every live command is always persisted for stats, `session_events` for stats, regardless of whether the session is ever replayed - replaying reads those same
regardless of whether the session is later saved as a named recording. events back directly, it doesn't create a new session or duplicate them, it just bumps the source session's
`playCount`/`lastPlayedAt`.
Renaming a table or column makes `drizzle-kit generate` prompt interactively ("is this a rename?"), which
needs a real TTY and won't work non-interactively (e.g. from an agent or CI). In that case, hand-author the
migration SQL and its `drizzle/meta/*_snapshot.json` instead, then confirm `pnpm db:generate` reports no
further changes against the updated schema.
## Docker deployment ## Docker deployment
@@ -116,18 +123,18 @@ Docker image to this repo's path on the `dev.pivoine.art` Gitea registry, authen
1. Log in with the shared secret. 1. Log in with the shared secret.
2. On **Control**, scan for devices and connect. Move the sliders to control actuators live. 2. On **Control**, scan for devices and connect. Move the sliders to control actuators live.
3. Click **Start session** before you begin if you want this session tracked in stats or saved as a 3. Click **Start session** before you begin; **End session** when done, and optionally name it so it's easy
recording; **End session** when done, and optionally name it to save it as a recording. to find later.
4. On **Recordings**, click replay on a saved recording, connect the devices you want to use, match them to 4. On **Sessions**, click replay on any completed session, connect the devices you want to use, match them
the recording's original device slots, and play back. to the session's original device slots, and play back.
5. **Sessions** and **Stats** show history and aggregated usage. 5. **Stats** shows aggregated usage across sessions and devices.
## Security & privacy notes ## Security & privacy notes
- This is a single shared-secret gate, not multi-user authentication - anyone with the secret has full - This is a single shared-secret gate, not multi-user authentication - anyone with the secret has full
access. Treat it like a shared house key; restricting network access (VPN, IP allowlist, Traefik access. Treat it like a shared house key; restricting network access (VPN, IP allowlist, Traefik
middleware) in addition to the app secret is recommended for anything beyond trusted personal use. middleware) in addition to the app secret is recommended for anything beyond trusted personal use.
- All data (recordings, session history, device names) stays in your own SQLite file. Nothing is sent - All data (session history, device names) stays in your own SQLite file. Nothing is sent
anywhere except directly between your browser and the devices it connects to, and between your browser anywhere except directly between your browser and the devices it connects to, and between your browser
and this server. and this server.
- There's no login rate-limiting in this version - acceptable behind a private deployment, but worth - There's no login rate-limiting in this version - acceptable behind a private deployment, but worth
+8 -2
View File
@@ -1,10 +1,16 @@
import { NavBar } from "@/components/layout/NavBar"; import { NavBar } from "@/components/layout/NavBar";
import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
import { Footer } from "@/components/layout/Footer";
export default function AppLayout({ children }: { children: React.ReactNode }) { export default function AppLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-dvh"> <div className="flex min-h-dvh flex-col">
<NavBar /> <NavBar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main> <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
<Breadcrumbs />
{children}
</main>
<Footer />
</div> </div>
); );
} }
+18 -56
View File
@@ -2,8 +2,8 @@ import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark"; import { BrandMark } from "@/components/layout/BrandMark";
import { listRecordings } from "@/lib/db/queries/recordings"; import { SessionsTable } from "@/components/sessions/SessionsTable";
import { listPlaySessions } from "@/lib/db/queries/play-sessions"; import { listSessions } from "@/lib/db/queries/sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats"; import { getSessionsSummary } from "@/lib/db/queries/stats";
// Always reflects live DB state for an authenticated, single-tenant app - // Always reflects live DB state for an authenticated, single-tenant app -
@@ -11,23 +11,18 @@ import { getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function DashboardPage() { export default async function DashboardPage() {
const [recordings, sessions, summary] = await Promise.all([ const [sessions, summary] = await Promise.all([listSessions(), getSessionsSummary()]);
listRecordings(),
listPlaySessions(),
getSessionsSummary(),
]);
const recentRecordings = recordings.slice(0, 5);
const recentSessions = [...sessions].reverse().slice(0, 5); const recentSessions = [...sessions].reverse().slice(0, 5);
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Card className="bp-glass overflow-hidden"> <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-8">
<BrandMark size={40} className="bp-pulse rounded-[9px]" /> <BrandMark size={48} animated className="rounded-[9px]" />
<h1 className="font-heading bp-gradient-text text-3xl font-semibold">Welcome back</h1> <h1 className="font-heading text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground"> <p className="max-w-xl text-sm text-muted-foreground">
Scan for nearby devices, take control, and record sessions to replay later - all running Scan for nearby devices, take control, and replay completed sessions later - all running
directly from your browser over Web Bluetooth. directly from your browser over Web Bluetooth.
</p> </p>
<Button asChild size="lg"> <Button asChild size="lg">
@@ -39,13 +34,13 @@ export default async function DashboardPage() {
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle> <CardTitle className="text-sm">Completed sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent> <CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle> <CardTitle className="text-sm">Total play time</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl"> <CardContent className="bp-readout text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h {(summary.totalDurationMs / 3_600_000).toFixed(1)}h
@@ -53,53 +48,20 @@ export default async function DashboardPage() {
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle> <CardTitle className="text-sm">Replays</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl">{recordings.length}</CardContent> <CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent>
</Card> </Card>
</div> </div>
<div className="grid gap-6 lg:grid-cols-2"> <Card className="bp-glass">
<Card className="bp-glass"> <CardHeader>
<CardHeader> <CardTitle className="text-base">Recent sessions</CardTitle>
<CardTitle>Recent recordings</CardTitle> </CardHeader>
</CardHeader> <CardContent className="flex flex-col gap-3">
<CardContent className="flex flex-col"> <SessionsTable sessions={recentSessions} />
{recentRecordings.length === 0 && ( </CardContent>
<p className="text-sm text-muted-foreground">Nothing saved yet.</p> </Card>
)}
{recentRecordings.map((r, i) => (
<Link key={r.id} href={`/recordings/${r.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">{r.name}</span>
<span className="bp-readout text-xs text-muted-foreground">{r.playCount} plays</span>
</div>
</Link>
))}
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle>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>
</Card>
</div>
</div> </div>
); );
} }
-81
View File
@@ -1,81 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getRecording, getRecordingName } from "@/lib/db/queries/recordings";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const name = await getRecordingName(Number(id));
return { title: name ?? "Recording" };
}
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export default async function RecordingDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) notFound();
const { recording } = result;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-semibold">{recording.name}</h1>
{recording.description && <p className="text-sm text-muted-foreground">{recording.description}</p>}
</div>
<Button asChild>
<Link href={`/recordings/${recording.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Details</CardTitle>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">Duration: </span>
{formatDuration(recording.durationMs)}
</div>
<div>
<span className="text-muted-foreground">Plays: </span>
{recording.playCount}
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(recording.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Last played: </span>
{recording.lastPlayedAt ? new Date(recording.lastPlayedAt).toLocaleString() : "Never"}
</div>
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Devices in this recording</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{recording.deviceSlots.map((slot) => (
<div key={slot.sourceSessionDeviceId} className="text-sm">
{slot.slotLabel} <span className="text-muted-foreground">({slot.recordedBleName})</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader";
import { getRecordingName } from "@/lib/db/queries/recordings";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const name = await getRecordingName(Number(id));
return { title: name ? `Replay ${name}` : "Replay" };
}
export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<div className="flex flex-col gap-6">
<h1 className="font-heading text-2xl font-semibold">Replay</h1>
<ReplayPlayerLoader recordingId={Number(id)} />
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecordingsTable } from "@/components/recordings/RecordingsTable";
import { PageNav } from "@/components/shared/PageNav";
import { listRecordingsPage } from "@/lib/db/queries/recordings";
import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Recordings" };
export default async function RecordingsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const page = parsePage((await searchParams).page);
const { items: recordings, pageSize, total } = await listRecordingsPage(page);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-heading text-2xl font-semibold">Recordings</h1>
<p className="text-sm text-muted-foreground">Saved sessions you can replay against connected devices.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Library</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<RecordingsTable recordings={recordings} />
<PageNav basePath="/recordings" page={page} pageSize={pageSize} total={total} />
</CardContent>
</Card>
</div>
);
}
+44 -8
View File
@@ -1,15 +1,23 @@
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { getPlaySessionDetail, getPlaySessionName } from "@/lib/db/queries/play-sessions"; 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 { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
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 getPlaySessionName(Number(id)); const name = await getSessionName(Number(id));
return { title: name ?? `Session #${id}` }; return { title: name ?? `Session #${id}` };
} }
@@ -22,16 +30,44 @@ function formatDuration(ms: number | null): string {
export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const sessionId = Number(id); const sessionId = Number(id);
const [detail, timeline] = await Promise.all([getPlaySessionDetail(sessionId), getSessionTimeline(sessionId)]); const [detail, timeline] = await Promise.all([getSessionDetail(sessionId), getSessionTimeline(sessionId)]);
if (!detail) notFound(); if (!detail) notFound();
const canReplay = detail.session.status === "completed" && detail.session.durationMs !== null;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1> <div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground"> <SessionTitleEditor sessionId={detail.session.id} initialName={detail.session.name} />
{detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)} {(detail.session.kind === "replay" || detail.session.playCount > 0) && (
</p> <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>
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { ReplayPlayerLoader } from "@/components/sessions/ReplayPlayerLoader";
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 ? `${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">
<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 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionsTable } from "@/components/sessions/SessionsTable"; import { SessionsTable } from "@/components/sessions/SessionsTable";
import { PageNav } from "@/components/shared/PageNav"; import { PageNav } from "@/components/shared/PageNav";
import { listPlaySessionsPage } from "@/lib/db/queries/play-sessions"; import { listSessionsPage } from "@/lib/db/queries/sessions";
import { parsePage } from "@/lib/pagination"; import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,7 +14,7 @@ export default async function SessionsPage({
searchParams: Promise<{ page?: string }>; searchParams: Promise<{ page?: string }>;
}) { }) {
const page = parsePage((await searchParams).page); const page = parsePage((await searchParams).page);
const { items: sessions, pageSize, total } = await listPlaySessionsPage(page); const { items: sessions, pageSize, total } = await listSessionsPage(page);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
+5 -22
View File
@@ -1,19 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats"; import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats";
import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Stats" }; export const metadata: Metadata = { title: "Stats" };
export default async function StatsPage() { export default async function StatsPage() {
const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([ const [sessionsSummary, deviceUsage, commandCounts] = await Promise.all([
getSessionsSummary(), getSessionsSummary(),
getDeviceUsageStats(), getDeviceUsageStats(),
getDeviceCommandCounts(), getDeviceCommandCounts(),
getRecordingLibraryStats(),
]); ]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
@@ -23,25 +20,11 @@ export default async function StatsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-heading text-2xl font-semibold">Stats</h1> <h1 className="font-heading text-2xl font-semibold">Stats</h1>
<p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p> <p className="text-sm text-muted-foreground">Usage across sessions and devices.</p>
</div> </div>
<Tabs defaultValue="sessions"> <SessionsSummaryCards summary={sessionsSummary} />
<TabsList> <DeviceUsageTable devices={devices} />
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger>
<TabsTrigger value="recordings">Recordings</TabsTrigger>
</TabsList>
<TabsContent value="sessions" className="pt-4">
<SessionsSummaryCards summary={sessionsSummary} />
</TabsContent>
<TabsContent value="devices" className="pt-4">
<DeviceUsageTable devices={devices} />
</TabsContent>
<TabsContent value="recordings" className="pt-4">
<RecordingLibraryStats stats={recordingStats} />
</TabsContent>
</Tabs>
</div> </div>
); );
} }
-56
View File
@@ -1,56 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
import { getRecordingsForSession } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
import { createLogger } from "@/lib/logger";
const log = createLogger("play-sessions");
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
export const GET = withRouteLogging(
"play-sessions.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const detail = await getPlaySessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
},
);
export const PATCH = withRouteLogging(
"play-sessions.end",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 });
}
const updated = await endPlaySession(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ playSession: updated });
},
);
export const DELETE = withRouteLogging(
"play-sessions.delete",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const cascade = new URL(req.url).searchParams.get("cascade") === "true";
try {
await deletePlaySession(Number(id), { cascade });
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
log.warn("blocked session delete due to referencing recording", { sessionId: id, cascade });
const blockingRecordings = await getRecordingsForSession(Number(id));
return NextResponse.json(
{ error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings },
{ status: 409 },
);
}
throw err;
}
},
);
-42
View File
@@ -1,42 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const patchSchema = z.object({
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
});
export const GET = withRouteLogging(
"recordings.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(result);
},
);
export const PATCH = withRouteLogging(
"recordings.update",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
const updated = await renameRecording(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ recording: updated });
},
);
export const DELETE = withRouteLogging(
"recordings.delete",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await deleteRecording(Number(id));
return NextResponse.json({ ok: true });
},
);
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const bodySchema = z.object({
sourcePlaySessionId: z.number().int().positive(),
name: z.string().min(1).max(160),
description: z.string().max(2000).optional(),
});
export const GET = withRouteLogging("recordings.list", async () => {
const rows = await listRecordings();
return NextResponse.json({ recordings: rows });
});
export const POST = withRouteLogging("recordings.create", async (req: Request) => {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const recording = await createRecording(parsed.data);
return NextResponse.json({ recording }, { status: 201 });
});
@@ -15,7 +15,7 @@ const eventSchema = z.object({
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) }); const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export const POST = withRouteLogging( export const POST = withRouteLogging(
"play-sessions.events.append", "sessions.events.append",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => { async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params; const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null)); const parsed = bodySchema.safeParse(await req.json().catch(() => null));
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getSessionForReplay, incrementSessionPlayCount } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export const GET = withRouteLogging(
"sessions.replay-data",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const result = await getSessionForReplay(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
if (result.session.durationMs === null) {
return NextResponse.json({ error: "session has no recorded duration yet" }, { status: 409 });
}
return NextResponse.json(result);
},
);
/**
* Marks the session as replayed (bumps playCount/lastPlayedAt). Replaying
* itself creates no session row or events of its own - the client just
* plays this session's already-recorded events back against newly-mapped
* devices, so this is the only DB write a replay run causes.
*/
export const POST = withRouteLogging(
"sessions.replay-start",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await incrementSessionPlayCount(Number(id), Date.now());
return NextResponse.json({ ok: true });
},
);
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteSession, endSession, getSessionDetail, renameSession } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const patchSchema = z
.object({
status: z.enum(["completed", "aborted"]).optional(),
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
})
.refine((v) => v.status !== undefined || v.name !== undefined || v.description !== undefined, {
message: "at least one of status, name, description is required",
});
export const GET = withRouteLogging(
"sessions.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const detail = await getSessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
},
);
export const PATCH = withRouteLogging(
"sessions.update",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { status, name, description } = parsed.data;
let updated;
if (status !== undefined) {
updated = await endSession(Number(id), { status });
}
if (name !== undefined || description !== undefined) {
updated = await renameSession(Number(id), { name, description });
}
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ session: updated });
},
);
export const DELETE = withRouteLogging(
"sessions.delete",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await deleteSession(Number(id));
return NextResponse.json({ ok: true });
},
);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions"; import { listSessions, startSession } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging"; import { withRouteLogging } from "@/lib/api/with-route-logging";
const deviceInputSchema = z.object({ const deviceInputSchema = z.object({
@@ -11,22 +11,20 @@ const deviceInputSchema = z.object({
}); });
const bodySchema = z.object({ const bodySchema = z.object({
kind: z.enum(["live", "replay"]),
replayedRecordingId: z.number().int().positive().optional(),
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
devices: z.array(deviceInputSchema).min(1), devices: z.array(deviceInputSchema).min(1),
}); });
export const GET = withRouteLogging("play-sessions.list", async () => { export const GET = withRouteLogging("sessions.list", async () => {
const rows = await listPlaySessions(); const rows = await listSessions();
return NextResponse.json({ playSessions: rows }); return NextResponse.json({ sessions: rows });
}); });
export const POST = withRouteLogging("play-sessions.start", async (req: Request) => { export const POST = withRouteLogging("sessions.start", async (req: Request) => {
const parsed = bodySchema.safeParse(await req.json().catch(() => null)); const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 }); return NextResponse.json({ error: parsed.error.message }, { status: 400 });
} }
const result = await startPlaySession(parsed.data); const result = await startSession(parsed.data);
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
}); });
-8
View File
@@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export const GET = withRouteLogging("stats.recordings", async () => {
const stats = await getRecordingLibraryStats();
return NextResponse.json(stats);
});
+83 -15
View File
@@ -127,13 +127,46 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
/* Animated signature-sweep background - sits behind everything; cards use
.bp-glass (translucent + blurred) so it shines through instead of being
fully hidden behind an opaque card surface. */
body {
background-color: var(--background);
background-image:
radial-gradient(ellipse 80% 60% at 15% 10%, color-mix(in srgb, #ff6fb0 32%, transparent), transparent 60%),
radial-gradient(ellipse 70% 60% at 85% 25%, color-mix(in srgb, #4f7fe0 28%, transparent), transparent 60%),
radial-gradient(ellipse 75% 65% at 50% 100%, color-mix(in srgb, #b34bde 30%, transparent), transparent 60%);
background-repeat: no-repeat;
background-size: 140% 140%;
background-attachment: fixed;
animation: bp-bg-drift 24s ease-in-out infinite;
}
@keyframes bp-bg-drift {
0% {
background-position: 0% 0%, 100% 0%, 50% 100%;
}
50% {
background-position: 20% 20%, 80% 30%, 60% 80%;
}
100% {
background-position: 0% 0%, 100% 0%, 50% 100%;
}
}
@media (prefers-reduced-motion: reduce) {
body {
animation: none;
}
}
/* Signature pink -> purple -> blue sweep (matches app/icon.svg), used for /* Signature pink -> purple -> blue sweep (matches app/icon.svg), used for
the wordmark and one hero moment - not smeared across every surface. */ the wordmark and one hero moment - not smeared across every surface. */
.bp-gradient-text { .bp-gradient-text {
@@ -147,21 +180,15 @@
background: linear-gradient(135deg, #ff6fb0, #b34bde 50%, #4f7fe0); background: linear-gradient(135deg, #ff6fb0, #b34bde 50%, #4f7fe0);
} }
/* Console-module surface: hairline border + a soft inset top highlight, /* Console-module surface: translucent + blurred so the animated background
standing in for backdrop-blur glassmorphism. */ sweep shines through. ring-0 zeroes out Card's own default
`ring-1 ring-foreground/10` (components/ui/card.tsx) - shadow-xl's
box-shadow formula reads the same --tw-ring-shadow custom property, so
without this the ring bleeds through regardless of shadow-xl "winning". */
.bp-glass { .bp-glass {
background: var(--card); @apply shadow-xl ring-0 border-0;
border: 1px solid var(--border); background: color-mix(in srgb, var(--card) 55%, transparent);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent); backdrop-filter: blur(20px) saturate(150%);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.bp-glass:hover {
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 /* Illuminated indicator dot - reused on connection status and per-actuator
@@ -201,6 +228,47 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
/* BrandMark's animated variant (dashboard hero): the heart glyph beats, the
EKG-style curve glows in the primary accent instead of plain white. */
@keyframes bp-mark-heartbeat {
0%,
100% {
opacity: 0.22;
transform: scale(1);
}
50% {
opacity: 0.75;
transform: scale(1.1);
}
}
.bp-mark-heart {
transform-box: fill-box;
transform-origin: center;
animation: bp-mark-heartbeat 1.8s ease-in-out infinite;
}
@keyframes bp-mark-curve-glow {
0%,
100% {
filter: drop-shadow(0 0 1px color-mix(in srgb, var(--primary) 50%, transparent));
}
50% {
filter:
drop-shadow(0 0 5px color-mix(in srgb, var(--primary) 100%, transparent))
drop-shadow(0 0 10px color-mix(in srgb, var(--primary) 70%, transparent));
}
}
.bp-mark-curve {
stroke: #fff;
animation: bp-mark-curve-glow 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bp-mark-heart,
.bp-mark-curve {
animation: none;
}
}
@keyframes bp-pulse-glow { @keyframes bp-pulse-glow {
0%, 0%,
100% { 100% {
+14 -12
View File
@@ -7,16 +7,18 @@
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGrad)" /> <rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGrad)" />
<path <g transform="translate(0 0.7)">
d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z" <path
fill="rgba(255,255,255,0.22)" d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z"
/> fill="rgba(255,255,255,0.22)"
<polyline />
points="6,15 10,15 12,9 15,21 18,13 20,15 26,15" <polyline
fill="none" points="6,15 10,15 12,9 15,21 18,13 20,15 26,15"
stroke="#ffffff" fill="none"
stroke-width="1.7" stroke="#ffffff"
stroke-linecap="round" stroke-width="1.7"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 884 B

After

Width:  |  Height:  |  Size: 950 B

+3 -3
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Inter, Space_Grotesk, JetBrains_Mono } from "next/font/google"; import { Inter, Sora, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/layout/ThemeProvider"; import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" }); const sora = Sora({ subsets: ["latin"], variable: "--font-display" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" }); const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -19,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html <html
lang="en" lang="en"
suppressHydrationWarning suppressHydrationWarning
className={cn("font-sans", inter.variable, spaceGrotesk.variable, jetbrainsMono.variable)} className={cn("font-sans", inter.variable, sora.variable, jetbrainsMono.variable)}
> >
<body> <body>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
+1 -1
View File
@@ -13,7 +13,7 @@ export default function LoginPage() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BrandMark size={32} /> <BrandMark size={32} />
<CardTitle className="font-heading bp-gradient-text text-2xl">Sexy</CardTitle> <CardTitle className="font-heading text-2xl">Sexy</CardTitle>
</div> </div>
<CardDescription>Enter the shared access secret to continue.</CardDescription> <CardDescription>Enter the shared access secret to continue.</CardDescription>
</CardHeader> </CardHeader>
+17 -15
View File
@@ -20,10 +20,12 @@ import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
import { DeviceScanPanel } from "./DeviceScanPanel"; import { DeviceScanPanel } from "./DeviceScanPanel";
import { DeviceCard } from "./DeviceCard"; import { DeviceCard } from "./DeviceCard";
import { RecordControls } from "./RecordControls"; import { RecordControls } from "./RecordControls";
import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog"; import { NameSessionDialog } from "./NameSessionDialog";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info } from "lucide-react";
interface ActivePlaySession { interface ActiveSession {
id: number; id: number;
startedAt: number; startedAt: number;
sessionDeviceIdByDeviceIndex: Map<number, number>; sessionDeviceIdByDeviceIndex: Map<number, number>;
@@ -49,7 +51,7 @@ export function ButtplugConsole() {
const setRecording = useButtplugStore((s) => s.setRecording); const setRecording = useButtplugStore((s) => s.setRecording);
const storeError = useButtplugStore((s) => s.error); const storeError = useButtplugStore((s) => s.error);
const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null); const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [elapsedMs, setElapsedMs] = useState(0); const [elapsedMs, setElapsedMs] = useState(0);
const [sessionBusy, setSessionBusy] = useState(false); const [sessionBusy, setSessionBusy] = useState(false);
const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null); const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null);
@@ -105,14 +107,13 @@ export function ButtplugConsole() {
try { try {
const deviceList = Object.values(devices); const deviceList = Object.values(devices);
const body = { const body = {
kind: "live" as const,
devices: deviceList.map((d) => ({ devices: deviceList.map((d) => ({
slotLabel: d.displayName ?? d.name, slotLabel: d.displayName ?? d.name,
bleName: d.name, bleName: d.name,
capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length }, capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length },
})), })),
}; };
const res = await fetch("/api/play-sessions", { const res = await fetch("/api/sessions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -151,7 +152,7 @@ export function ButtplugConsole() {
setSessionBusy(true); setSessionBusy(true);
eventBuffer.stop(); eventBuffer.stop();
setRecording(false); setRecording(false);
await fetch(`/api/play-sessions/${activeSession.id}`, { await fetch(`/api/sessions/${activeSession.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "completed" }), body: JSON.stringify({ status: "completed" }),
@@ -226,7 +227,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,12 +242,13 @@ export function ButtplugConsole() {
{storeError && <p className="text-sm text-destructive">{storeError}</p>} {storeError && <p className="text-sm text-destructive">{storeError}</p>}
{Object.keys(devices).length === 0 ? ( {Object.keys(devices).length === 0 ? (
<Card className="bp-glass"> <Alert className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground"> <Info />
No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s <AlertTitle>No devices connected yet</AlertTitle>
pairing prompt. <AlertDescription>
</CardContent> Scan to discover nearby toys, then select one from your browser&apos;s pairing prompt.
</Card> </AlertDescription>
</Alert>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{Object.values(devices).map((device) => ( {Object.values(devices).map((device) => (
@@ -270,8 +272,8 @@ export function ButtplugConsole() {
</div> </div>
)} )}
<SaveRecordingDialog <NameSessionDialog
playSessionId={savePromptSessionId} sessionId={savePromptSessionId}
onClose={() => setSavePromptSessionId(null)} onClose={() => setSavePromptSessionId(null)}
onSaved={() => setSavePromptSessionId(null)} onSaved={() => setSavePromptSessionId(null)}
/> />
+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} />
@@ -14,50 +14,50 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
interface SaveRecordingDialogProps { interface NameSessionDialogProps {
playSessionId: number | null; sessionId: number | null;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
} }
export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRecordingDialogProps) { export function NameSessionDialog({ sessionId, onClose, onSaved }: NameSessionDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function handleSave() { async function handleSave() {
if (!playSessionId || name.trim().length === 0) return; if (!sessionId || name.trim().length === 0) return;
setSaving(true); setSaving(true);
const res = await fetch("/api/recordings", { const res = await fetch(`/api/sessions/${sessionId}`, {
method: "POST", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourcePlaySessionId: playSessionId, name: name.trim(), description }), body: JSON.stringify({ name: name.trim(), description }),
}); });
setSaving(false); setSaving(false);
if (res.ok) { if (res.ok) {
toast.success("Recording saved"); toast.success("Session named");
onSaved(); onSaved();
} else { } else {
toast.error("Could not save recording"); toast.error("Could not name session");
} }
} }
return ( return (
<Dialog open={playSessionId !== null} onOpenChange={(open) => !open && onClose()}> <Dialog open={sessionId !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Save session as a recording</DialogTitle> <DialogTitle>Name this session</DialogTitle>
<DialogDescription>Give it a name so you can find and replay it later.</DialogDescription> <DialogDescription>Give it a name so you can find and replay it later.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="recording-name">Name</Label> <Label htmlFor="session-name">Name</Label>
<Input id="recording-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus /> <Input id="session-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="recording-description">Description (optional)</Label> <Label htmlFor="session-description">Description (optional)</Label>
<Input <Input
id="recording-description" id="session-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
@@ -68,7 +68,7 @@ export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRec
Skip Skip
</Button> </Button>
<Button onClick={handleSave} disabled={saving || name.trim().length === 0}> <Button onClick={handleSave} disabled={saving || name.trim().length === 0}>
{saving ? "Saving..." : "Save recording"} {saving ? "Saving..." : "Save name"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+3 -3
View File
@@ -15,12 +15,12 @@ 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}
</span> </span>
<Button variant="outline" size="sm" onClick={onEnd} disabled={busy}> <Button variant="outline" onClick={onEnd} disabled={busy}>
<Square className="size-3.5" /> End session <Square className="size-3.5" /> End session
</Button> </Button>
</div> </div>
@@ -28,7 +28,7 @@ export function RecordControls({ active, elapsedLabel, disabled, busy, onStart,
} }
return ( return (
<Button onClick={onStart} disabled={disabled || busy} size="sm"> <Button onClick={onStart} disabled={disabled || busy}>
<Circle className="size-3.5" /> Start session <Circle className="size-3.5" /> Start session
</Button> </Button>
); );
+58 -25
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
import { 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 { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
export interface DeviceRow { export interface DeviceRow {
id: number; id: number;
@@ -14,34 +14,67 @@ export interface DeviceRow {
lastConnectedAt: number | null; lastConnectedAt: number | null;
} }
const AUTOSAVE_DELAY_MS = 600;
function DeviceNameCell({ device }: { device: DeviceRow }) { function DeviceNameCell({ device }: { device: DeviceRow }) {
const router = useRouter(); const router = useRouter();
const [value, setValue] = useState(device.displayName ?? device.bleName); const initial = device.displayName ?? device.bleName;
const [saving, setSaving] = useState(false); const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
async function handleSave() { useEffect(() => {
if (value.trim().length === 0) return; const trimmed = value.trim();
setSaving(true); if (trimmed.length === 0 || trimmed === lastSaved.current) return;
const res = await fetch(`/api/devices/${device.id}`, {
method: "PATCH", setStatus("saving");
headers: { "Content-Type": "application/json" }, const timer = setTimeout(async () => {
body: JSON.stringify({ displayName: value.trim() }), const res = await fetch(`/api/devices/${device.id}`, {
}); method: "PATCH",
setSaving(false); headers: { "Content-Type": "application/json" },
if (res.ok) { body: JSON.stringify({ displayName: trimmed }),
toast.success("Renamed"); });
router.refresh(); if (res.ok) {
} else { lastSaved.current = trimmed;
toast.error("Could not rename device"); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 w-40 min-w-40" /> <input
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving} className="shrink-0"> value={value}
Save onChange={(e) => setValue(e.target.value)}
</Button> 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> </div>
); );
} }
+19 -13
View File
@@ -3,10 +3,12 @@ import { cn } from "@/lib/utils";
interface BrandMarkProps { interface BrandMarkProps {
size?: number; size?: number;
className?: string; className?: string;
/** Beats the heart glyph and makes the curve glow - used for the dashboard hero mark. */
animated?: boolean;
} }
/** Inline twin of app/icon.svg (the favicon) - kept as markup, not an <img>, so it can be sized/animated with CSS. */ /** Inline twin of app/icon.svg (the favicon) - kept as markup, not an <img>, so it can be sized/animated with CSS. */
export function BrandMark({ size = 28, className }: BrandMarkProps) { export function BrandMark({ size = 28, className, animated = false }: BrandMarkProps) {
return ( return (
<svg <svg
width={size} width={size}
@@ -25,18 +27,22 @@ export function BrandMark({ size = 28, className }: BrandMarkProps) {
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGradMark)" /> <rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGradMark)" />
<path <g transform="translate(0 0.7)">
d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z" <path
fill="rgba(255,255,255,0.22)" d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z"
/> fill="rgba(255,255,255,0.22)"
<polyline className={cn(animated && "bp-mark-heart")}
points="6,15 10,15 12,9 15,21 18,13 20,15 26,15" />
fill="none" <polyline
stroke="#ffffff" points="6,15 10,15 12,9 15,21 18,13 20,15 26,15"
strokeWidth={1.7} fill="none"
strokeLinecap="round" stroke="#ffffff"
strokeLinejoin="round" strokeWidth={1.7}
/> strokeLinecap="round"
strokeLinejoin="round"
className={cn(animated && "bp-mark-curve")}
/>
</g>
</svg> </svg>
); );
} }
+54
View File
@@ -0,0 +1,54 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight, House } from "lucide-react";
const SECTION_LABELS: Record<string, string> = {
control: "Control",
sessions: "Sessions",
stats: "Stats",
devices: "Devices",
};
const LEAF_LABELS: Record<string, string> = {
replay: "Replay",
};
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) {
return null;
}
const crumbs = segments.reduce<{ label: string; href: string }[]>((acc, segment) => {
const href = `${acc.at(-1)?.href ?? ""}/${segment}`;
const label = /^\d+$/.test(segment) ? `#${segment}` : (LEAF_LABELS[segment] ?? SECTION_LABELS[segment] ?? segment);
return [...acc, { label, href }];
}, []);
return (
<nav aria-label="Breadcrumb" className="bp-glass mb-6 flex w-fit items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-muted-foreground">
<Link href="/" className="flex items-center hover:text-foreground" aria-label="Dashboard">
<House className="size-3.5" />
</Link>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={crumb.href} className="flex items-center gap-1.5">
<ChevronRight className="size-3.5 text-muted-foreground/50" />
{isLast ? (
<span className="font-medium text-foreground">{crumb.label}</span>
) : (
<Link href={crumb.href} className="hover:text-foreground">
{crumb.label}
</Link>
)}
</span>
);
})}
</nav>
);
}
+32
View File
@@ -0,0 +1,32 @@
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mx-auto w-full max-w-6xl px-4 py-6">
<div className="flex flex-col items-center gap-1 text-center text-xs text-muted-foreground">
<p>
Made with <span aria-hidden="true">💜</span> by{" "}
<a
href="https://dev.pivoine.art/valknar/sexy"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Valknar
</a>
</p>
<p>
&copy; {year} Sexy · Powered by{" "}
<a
href="https://buttplug.io/"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Buttplug.io
</a>
</p>
</div>
</footer>
);
}
+143 -43
View File
@@ -1,50 +1,113 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes"; 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 { 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 { ConnectionStatus } from "./ConnectionStatus";
import { BrandMark } from "./BrandMark"; import { BrandMark } from "./BrandMark";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const LINKS = [ const LINKS = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard", icon: Gauge },
{ href: "/control", label: "Control" }, { href: "/control", label: "Control", icon: SlidersHorizontal },
{ href: "/recordings", label: "Recordings" }, { href: "/sessions", label: "Sessions", icon: ListMusic },
{ href: "/sessions", label: "Sessions" }, { href: "/devices", label: "Devices", icon: Bluetooth },
{ href: "/stats", label: "Stats" }, { href: "/stats", label: "Stats", icon: BarChart3 },
{ href: "/devices", label: "Devices" },
]; ];
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 }) { function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<> <>
{LINKS.map((link) => ( {LINKS.map((link) => {
<Link const active = isActive(pathname, link.href);
key={link.href} return (
href={link.href} <Link
onClick={onNavigate} key={link.href}
className={cn( href={link.href}
"border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors", onClick={onNavigate}
pathname === link.href className={cn(
? "border-primary text-foreground" "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors",
: "border-transparent text-muted-foreground hover:text-foreground", active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:text-foreground",
)} )}
> >
{link.label} <link.icon className={cn("size-3.5", active && "text-primary")} />
</Link> {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() { export function NavBar() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const router = useRouter(); const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
async function handleLogout() { async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" }); await fetch("/api/auth/logout", { method: "POST" });
@@ -57,39 +120,76 @@ export function NavBar() {
<div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4"> <div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4">
<Link href="/" className="mr-2 flex items-center gap-2"> <Link href="/" className="mr-2 flex items-center gap-2">
<BrandMark size={26} /> <BrandMark size={26} />
<span className="font-heading bp-gradient-text text-lg font-semibold">Sexy</span> <span className="font-heading text-lg font-semibold">Sexy</span>
</Link> </Link>
<nav className="hidden items-center gap-1 md:flex"> <nav className="hidden items-center gap-1.5 md:flex">
<NavLinks /> <NavLinks />
</nav> </nav>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<ConnectionStatus /> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
aria-label="Toggle theme" className="md:hidden"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} aria-label={menuOpen ? "Close menu" : "Open menu"}
onClick={() => setMenuOpen((open) => !open)}
> >
<Sun className="hidden size-4 dark:block" /> <MenuToggleIcon open={menuOpen} />
<Moon className="block size-4 dark:hidden" />
</Button>
<Button variant="ghost" size="icon" aria-label="Log out" onClick={handleLogout}>
<LogOut className="size-4" />
</Button> </Button>
<Sheet> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetTrigger asChild> <SheetContent side="right" className="w-72 gap-0 p-0">
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu"> <div className="flex items-center gap-2 px-4 pt-4 pb-3">
<Menu className="size-4" /> <BrandMark size={24} animated />
</Button> <SheetTitle className="font-heading text-lg font-semibold">Sexy</SheetTitle>
</SheetTrigger> </div>
<SheetContent side="right" className="w-64"> <SheetDescription className="px-4 pb-3 text-xs">
<SheetTitle className="px-4 pt-4">Menu</SheetTitle> Jump to another page or manage your session.
<nav className="flex flex-col gap-1 p-4"> </SheetDescription>
<NavLinks /> <div className="bp-hairline" />
<nav className="flex flex-1 flex-col gap-1 p-3">
<MobileNavLinks onNavigate={() => setMenuOpen(false)} />
</nav> </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> </SheetContent>
</Sheet> </Sheet>
</div> </div>
-91
View File
@@ -1,91 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
export interface RecordingRow {
id: number;
name: string;
durationMs: number;
playCount: number;
lastPlayedAt: number | null;
createdAt: number;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function RecordingsTable({ recordings }: { recordings: RecordingRow[] }) {
const router = useRouter();
async function handleDelete(id: number) {
const res = await fetch(`/api/recordings/${id}`, { method: "DELETE" });
if (res.ok) {
toast.success("Recording deleted");
router.refresh();
} else {
toast.error("Could not delete recording");
}
}
if (recordings.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No recordings yet - start a session on the Control page and save it when you&apos;re done.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Plays</TableHead>
<TableHead>Last played</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordings.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
<Link href={`/recordings/${r.id}`} className="hover:underline">
{r.name}
</Link>
</TableCell>
<TableCell className="bp-readout">{formatDuration(r.durationMs)}</TableCell>
<TableCell>
<Badge variant="secondary" className="bp-readout">
{r.playCount}
</Badge>
</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell>
<TableCell className="flex justify-end gap-1">
<Button asChild variant="ghost" size="icon-sm">
<Link href={`/recordings/${r.id}/replay`} aria-label="Replay">
<Play className="size-3.5" />
</Link>
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(r.id)} aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
-298
View File
@@ -1,298 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
import { DeviceRemapDialog } from "./DeviceRemapDialog";
import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client";
import { eventBuffer } from "@/lib/buttplug/event-buffer";
import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player";
import { useButtplugStore } from "@/lib/buttplug/store";
import type { ActuatorInfo } from "@/lib/buttplug/types";
import type { RecordingDeviceSlot } from "@/lib/db/schema";
import { Pause, Play } from "lucide-react";
interface RecordingResponse {
recording: {
id: number;
name: string;
durationMs: number;
deviceSlots: RecordingDeviceSlot[];
};
events: RecordingEventRow[];
}
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export function ReplayPlayer({ recordingId }: { recordingId: number }) {
const router = useRouter();
const scanning = useButtplugStore((s) => s.scanning);
const devices = useButtplugStore((s) => s.devices);
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const removeDevice = useButtplugStore((s) => s.removeDevice);
const connectedDevices = useMemo(() => Object.values(devices), [devices]);
const [data, setData] = useState<RecordingResponse | null>(null);
const [showRemap, setShowRemap] = useState(false);
const [starting, setStarting] = useState(false);
const [player, setPlayer] = useState<RecordingPlayer | null>(null);
const [playing, setPlaying] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]);
const [replayDeviceIndexes, setReplayDeviceIndexes] = useState<number[]>([]);
const [activeSessionId, setActiveSessionId] = useState<number | null>(null);
useEffect(() => {
fetch(`/api/recordings/${recordingId}`)
.then((r) => r.json())
.then(setData)
.catch(() => toast.error("Could not load recording"));
}, [recordingId]);
useEffect(() => {
return () => {
void disconnectAll();
};
}, []);
async function resetReplayDevices() {
await Promise.all(
replayDeviceIndexes.map(async (deviceIndex) => {
const liveDevice = await getDevice(deviceIndex);
await liveDevice?.stop().catch(() => {});
devices[deviceIndex]?.actuators.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
}),
);
}
async function handleConfirmRemap(mapping: Map<number, number>) {
if (!data) return;
setShowRemap(false);
setStarting(true);
try {
const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
const targets = orderedSlots.map((slot) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const device = connectedDevices.find((d) => d.index === deviceIndex)!;
return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name };
});
const body = {
kind: "replay" as const,
replayedRecordingId: data.recording.id,
devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })),
};
const res = await fetch("/api/play-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
toast.error("Could not start replay session");
return;
}
const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
await res.json();
eventBuffer.start(result.session.id, result.session.startedAt);
const sessionDeviceIdToDeviceIndex = new Map<number, number>();
orderedSlots.forEach((slot, i) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const newSessionDeviceId = result.sessionDevices[i]?.id;
if (newSessionDeviceId !== undefined) {
eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId);
sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex);
}
});
const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>(
connectedDevices.map((d) => [d.index, d.actuators]),
);
// Reset every actuator on every device that will take part in this replay before
// the first scheduled event fires, so playback always starts from a known-zero
// state rather than whatever intensity was left over from manual control.
const deviceIndexes = new Set(sessionDeviceIdToDeviceIndex.values());
await Promise.all(
[...deviceIndexes].map(async (deviceIndex) => {
const liveDevice = await getDevice(deviceIndex);
await liveDevice?.stop().catch(() => {});
actuatorsByDeviceIndex.get(deviceIndex)?.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
}),
);
const { runtime } = await getButtplugClientHandle();
const instance = new RecordingPlayer({
events: data.events,
durationMs: data.recording.durationMs,
sessionDeviceIdToDeviceIndex,
actuatorsByDeviceIndex,
runtime,
onProgress: (elapsed) => setElapsedMs(elapsed),
onError: (message) => toast.error(`Replay command failed: ${message}`),
onComplete: async () => {
setPlaying(false);
eventBuffer.stop();
await fetch(`/api/play-sessions/${result.session.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "completed" }),
}).catch(() => {});
toast.success("Replay finished");
},
});
setReplayTargets(targets);
setReplayDeviceIndexes([...deviceIndexes]);
setActiveSessionId(result.session.id);
setPlayer(instance);
instance.play();
setPlaying(true);
} catch {
toast.error("Could not start replay");
} finally {
setStarting(false);
}
}
if (!data) {
return <p className="text-sm text-muted-foreground">Loading recording...</p>;
}
return (
<div className="flex flex-col gap-6">
<Card className="bp-glass">
<CardHeader>
<CardTitle>{data.recording.name}</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 recording&apos;s
original device slots.
</p>
<div className="flex 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..." : "Match devices & replay"}
</Button>
</div>
</>
) : (
<div className="flex flex-col gap-3">
{replayTargets.length > 0 && (
<div className="flex flex-wrap gap-2">
{replayTargets.map((t) => (
<span
key={t.slotLabel}
className="bp-readout rounded-md border border-border bg-card/60 px-2 py-1 text-xs text-muted-foreground"
>
{t.slotLabel} {t.deviceName}
</span>
))}
</div>
)}
<Slider
value={[elapsedMs]}
max={data.recording.durationMs}
onValueChange={([v]) => player.seek(v)}
/>
<div className="flex items-center justify-between">
<span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.recording.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);
eventBuffer.stop();
if (activeSessionId !== null) {
await fetch(`/api/play-sessions/${activeSessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "aborted" }),
}).catch(() => {});
}
// Fully disconnect every device that took part in this replay,
// not just stop the player - see handleDisconnectDevice in
// ButtplugConsole for why "stop + remove from store" is the
// closest equivalent Buttplug's protocol allows per-device.
await Promise.all(
replayDeviceIndexes.map(async (deviceIndex) => {
const liveDevice = await getDevice(deviceIndex);
await liveDevice?.stop().catch(() => {});
removeDevice(deviceIndex);
}),
);
setPlayer(null);
setReplayTargets([]);
setReplayDeviceIndexes([]);
setActiveSessionId(null);
}}
>
Stop
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<DeviceRemapDialog
open={showRemap}
deviceSlots={data.recording.deviceSlots}
connectedDevices={connectedDevices}
onCancel={() => setShowRemap(false)}
onConfirm={(mapping) => void handleConfirmRemap(mapping)}
/>
<Button variant="ghost" size="sm" onClick={() => router.push("/recordings")}>
Back to recordings
</Button>
</div>
);
}
@@ -12,12 +12,11 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap"; import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap";
import type { RecordingDeviceSlot } from "@/lib/db/schema"; import type { ReplayDeviceSlot, ConnectedDeviceInfo } from "@/lib/buttplug/types";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
interface DeviceRemapDialogProps { interface DeviceRemapDialogProps {
open: boolean; open: boolean;
deviceSlots: RecordingDeviceSlot[]; deviceSlots: ReplayDeviceSlot[];
connectedDevices: ConnectedDeviceInfo[]; connectedDevices: ConnectedDeviceInfo[];
onCancel: () => void; onCancel: () => void;
onConfirm: (mapping: Map<number, number>) => void; onConfirm: (mapping: Map<number, number>) => void;
+261
View File
@@ -0,0 +1,261 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
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";
import { DeviceRemapDialog } from "./DeviceRemapDialog";
import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client";
import { SessionPlayer, type SessionEventRow } from "@/lib/buttplug/player";
import { useButtplugStore } from "@/lib/buttplug/store";
import type { ActuatorInfo, ReplayDeviceSlot } from "@/lib/buttplug/types";
import { Info, Pause, Play } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface ReplayDataResponse {
session: {
id: number;
name: string | null;
durationMs: number;
};
deviceSlots: ReplayDeviceSlot[];
events: SessionEventRow[];
}
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export function ReplayPlayer({ sessionId }: { sessionId: number }) {
const scanning = useButtplugStore((s) => s.scanning);
const devices = useButtplugStore((s) => s.devices);
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const removeDevice = useButtplugStore((s) => s.removeDevice);
const connectedDevices = useMemo(() => Object.values(devices), [devices]);
const [data, setData] = useState<ReplayDataResponse | null>(null);
const [showRemap, setShowRemap] = useState(false);
const [starting, setStarting] = useState(false);
const [player, setPlayer] = useState<SessionPlayer | null>(null);
const [playing, setPlaying] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]);
const [replayDeviceIndexes, setReplayDeviceIndexes] = useState<number[]>([]);
useEffect(() => {
fetch(`/api/sessions/${sessionId}/replay`)
.then((r) => r.json())
.then(setData)
.catch(() => toast.error("Could not load session"));
}, [sessionId]);
useEffect(() => {
return () => {
void disconnectAll();
};
}, []);
async function resetReplayDevices() {
await Promise.all(
replayDeviceIndexes.map(async (deviceIndex) => {
const liveDevice = await getDevice(deviceIndex);
await liveDevice?.stop().catch(() => {});
devices[deviceIndex]?.actuators.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
}),
);
}
async function handleConfirmRemap(mapping: Map<number, number>) {
if (!data) return;
setShowRemap(false);
setStarting(true);
try {
const orderedSlots = data.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
const targets = orderedSlots.map((slot) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const device = connectedDevices.find((d) => d.index === deviceIndex)!;
return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name };
});
// Replaying creates no session or events of its own - it just plays this
// session's already-recorded events back onto the mapped devices, and
// marks it as replayed (playCount/lastPlayedAt).
void fetch(`/api/sessions/${data.session.id}/replay`, { method: "POST" }).catch(() => {});
// `mapping` is already keyed by the source session's session_device id
// (see DeviceRemapDialog), exactly what SessionPlayer needs.
const sessionDeviceIdToDeviceIndex = mapping;
const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>(
connectedDevices.map((d) => [d.index, d.actuators]),
);
// Reset every actuator on every device that will take part in this replay before
// the first scheduled event fires, so playback always starts from a known-zero
// state rather than whatever intensity was left over from manual control.
const deviceIndexes = new Set(sessionDeviceIdToDeviceIndex.values());
await Promise.all(
[...deviceIndexes].map(async (deviceIndex) => {
const liveDevice = await getDevice(deviceIndex);
await liveDevice?.stop().catch(() => {});
actuatorsByDeviceIndex.get(deviceIndex)?.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0));
}),
);
const { runtime } = await getButtplugClientHandle();
const instance = new SessionPlayer({
events: data.events,
durationMs: data.session.durationMs,
sessionDeviceIdToDeviceIndex,
actuatorsByDeviceIndex,
runtime,
onProgress: (elapsed) => setElapsedMs(elapsed),
onError: (message) => toast.error(`Replay command failed: ${message}`),
onComplete: () => {
setPlaying(false);
toast.success("Replay finished");
},
});
setReplayTargets(targets);
setReplayDeviceIndexes([...deviceIndexes]);
setPlayer(instance);
instance.play();
setPlaying(true);
} catch {
toast.error("Could not start replay");
} finally {
setStarting(false);
}
}
if (!data) {
return <p className="text-sm text-muted-foreground">Loading session...</p>;
}
return (
<div className="flex flex-col gap-6">
{!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 () => {
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>
</CardContent>
</Card>
)}
<DeviceRemapDialog
open={showRemap}
deviceSlots={data.deviceSlots}
connectedDevices={connectedDevices}
onCancel={() => setShowRemap(false)}
onConfirm={(mapping) => void handleConfirmRemap(mapping)}
/>
</div>
);
}
@@ -8,6 +8,6 @@ const ReplayPlayer = dynamic(() => import("./ReplayPlayer").then((m) => m.Replay
loading: () => <Skeleton className="h-64 w-full" />, loading: () => <Skeleton className="h-64 w-full" />,
}); });
export function ReplayPlayerLoader({ recordingId }: { recordingId: number }) { export function ReplayPlayerLoader({ sessionId }: { sessionId: number }) {
return <ReplayPlayer recordingId={recordingId} />; return <ReplayPlayer sessionId={sessionId} />;
} }
@@ -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>
);
}
+28 -41
View File
@@ -14,18 +14,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { 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";
interface BlockingRecording {
id: number;
name: string;
}
export interface SessionRow { export interface SessionRow {
id: number; id: number;
name: string | null; name: string | null;
kind: "live" | "replay";
status: "active" | "completed" | "aborted"; status: "active" | "completed" | "aborted";
startedAt: number; startedAt: number;
durationMs: number | null; durationMs: number | null;
@@ -41,30 +36,17 @@ function formatDuration(ms: number | null): string {
export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
const router = useRouter(); const router = useRouter();
const [conflict, setConflict] = useState<{ id: number; recordings: BlockingRecording[] } | null>(null); const [pendingDelete, setPendingDelete] = useState<SessionRow | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
async function handleDelete(id: number) { async function handleDelete() {
const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" }); if (!pendingDelete) return;
if (res.ok) {
toast.success("Session deleted");
router.refresh();
} else if (res.status === 409) {
const body: { recordings?: BlockingRecording[] } = await res.json().catch(() => ({}));
setConflict({ id, recordings: body.recordings ?? [] });
} else {
toast.error("Could not delete session");
}
}
async function handleCascadeDelete() {
if (!conflict) return;
setDeleting(true); setDeleting(true);
const res = await fetch(`/api/play-sessions/${conflict.id}?cascade=true`, { method: "DELETE" }); const res = await fetch(`/api/sessions/${pendingDelete.id}`, { method: "DELETE" });
setDeleting(false); setDeleting(false);
if (res.ok) { if (res.ok) {
toast.success("Session and recording deleted"); toast.success("Session deleted");
setConflict(null); setPendingDelete(null);
router.refresh(); router.refresh();
} else { } else {
toast.error("Could not delete session"); toast.error("Could not delete session");
@@ -81,7 +63,6 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Session</TableHead> <TableHead>Session</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Started</TableHead> <TableHead>Started</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
@@ -97,15 +78,21 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge> <Badge variant={STATUS_VARIANT[s.status]}>{s.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{s.status}</TableCell>
<TableCell className="bp-readout text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{new Date(s.startedAt).toLocaleString()} {new Date(s.startedAt).toLocaleString()}
</TableCell> </TableCell>
<TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell> <TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell>
<TableCell className="text-right"> <TableCell className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete"> {s.status === "completed" && s.durationMs !== null && (
<Button asChild variant="ghost" size="icon-sm">
<Link href={`/sessions/${s.id}/replay`} aria-label="Replay">
<Play className="size-3.5" />
</Link>
</Button>
)}
<Button variant="ghost" size="icon-sm" onClick={() => setPendingDelete(s)} aria-label="Delete">
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</TableCell> </TableCell>
@@ -114,24 +101,24 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
</TableBody> </TableBody>
</Table> </Table>
<Dialog open={conflict !== null} onOpenChange={(open) => !open && setConflict(null)}> <Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && setPendingDelete(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete session and recording?</DialogTitle> <DialogTitle>Delete session?</DialogTitle>
<DialogDescription> <DialogDescription>
This session has a saved recording This permanently deletes{" "}
{conflict && conflict.recordings.length > 0 <span className="font-medium text-foreground">
? ` (${conflict.recordings.map((r) => r.name).join(", ")})` {pendingDelete?.name ?? `Session #${pendingDelete?.id}`}
: ""}{" "} </span>{" "}
that still points to it. Deleting the session will also delete that recording. and all of its recorded events. This can&apos;t be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setConflict(null)}> <Button variant="ghost" onClick={() => setPendingDelete(null)}>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={() => void handleCascadeDelete()} disabled={deleting}> <Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting ? "Deleting..." : "Delete both"} {deleting ? "Deleting..." : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+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;
+40 -25
View File
@@ -1,3 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export interface DeviceUsageRow { export interface DeviceUsageRow {
@@ -12,33 +13,47 @@ export interface DeviceUsageRow {
export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) { export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
if (devices.length === 0) { if (devices.length === 0) {
return <p className="text-sm text-muted-foreground">No device activity yet.</p>; return (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">No device activity yet.</CardContent>
</Card>
);
} }
return ( return (
<Table> <Card className="bp-glass">
<TableHeader> <CardHeader>
<TableRow> <CardTitle className="text-base">Device usage</CardTitle>
<TableHead>Device</TableHead> </CardHeader>
<TableHead>Sessions</TableHead> <CardContent>
<TableHead>Active time</TableHead> <Table>
<TableHead>Commands</TableHead> <TableHeader>
<TableHead>Last used</TableHead> <TableRow>
</TableRow> <TableHead>Device</TableHead>
</TableHeader> <TableHead>Sessions</TableHead>
<TableBody> <TableHead>Active time</TableHead>
{devices.map((d) => ( <TableHead>Commands</TableHead>
<TableRow key={d.deviceId}> <TableHead>Last used</TableHead>
<TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell> </TableRow>
<TableCell className="bp-readout">{d.sessionCount}</TableCell> </TableHeader>
<TableCell className="bp-readout">{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell> <TableBody>
<TableCell className="bp-readout">{d.commandCount}</TableCell> {devices.map((d) => (
<TableCell className="bp-readout text-muted-foreground"> <TableRow key={d.deviceId}>
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"} <TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell>
</TableCell> <TableCell className="bp-readout">{d.sessionCount}</TableCell>
</TableRow> <TableCell className="bp-readout">{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
))} <TableCell className="bp-readout">{d.commandCount}</TableCell>
</TableBody> <TableCell className="bp-readout text-muted-foreground">
</Table> {d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
); );
} }
@@ -1,61 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export interface RecordingLibrarySummary {
count: number;
avgDurationMs: number;
totalPlayCount: number;
list: { id: number; name: string; durationMs: number; playCount: number; lastPlayedAt: number | null }[];
}
export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummary }) {
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">Recordings</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{stats.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(stats.avgDurationMs / 1000)}s
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total plays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{stats.totalPlayCount}</CardContent>
</Card>
</div>
{stats.list.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Recording</TableHead>
<TableHead>Plays</TableHead>
<TableHead>Last played</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.list.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="bp-readout">{r.playCount}</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}
+32 -46
View File
@@ -4,60 +4,46 @@ export interface SessionsSummary {
count: number; count: number;
totalDurationMs: number; totalDurationMs: number;
avgDurationMs: number; avgDurationMs: number;
byKind: { kind: "live" | "replay"; count: number; totalDurationMs: number }[]; totalReplays: number;
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
} }
function formatHours(ms: number): string { function formatHours(ms: number): string {
return `${(ms / 3_600_000).toFixed(1)}h`; return `${(ms / 3_600_000).toFixed(1)}h`;
} }
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) { function formatReplays(count: number): string {
const liveCount = summary.byKind.find((k) => k.kind === "live")?.count ?? 0; return count === 0 ? "-" : `${count}×`;
const replayCount = summary.byKind.find((k) => k.kind === "replay")?.count ?? 0; }
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
return ( return (
<div className="flex flex-col gap-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-3"> <Card className="bp-glass">
<Card className="bp-glass"> <CardHeader>
<CardHeader> <CardTitle className="text-sm">Completed sessions</CardTitle>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle> </CardHeader>
</CardHeader> <CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent> </Card>
</Card> <Card className="bp-glass">
<Card className="bp-glass"> <CardHeader>
<CardHeader> <CardTitle className="text-sm">Total time</CardTitle>
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle> </CardHeader>
</CardHeader> <CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
<CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent> </Card>
</Card> <Card className="bp-glass">
<Card className="bp-glass"> <CardHeader>
<CardHeader> <CardTitle className="text-sm">Avg session length</CardTitle>
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle> </CardHeader>
</CardHeader> <CardContent className="bp-readout text-3xl">
<CardContent className="bp-readout text-3xl"> {Math.round(summary.avgDurationMs / 60_000)}m
{Math.round(summary.avgDurationMs / 60_000)}m </CardContent>
</CardContent> </Card>
</Card> <Card className="bp-glass">
</div> <CardHeader>
<p className="text-sm text-muted-foreground"> <CardTitle className="text-sm">Replays</CardTitle>
{liveCount} live · {replayCount} replay </CardHeader>
</p> <CardContent className="bp-readout text-3xl">{formatReplays(summary.totalReplays)}</CardContent>
{summary.durationPerDevice.length > 0 && ( </Card>
<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> </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 }
+17
View File
@@ -0,0 +1,17 @@
DROP TABLE `recordings`;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_play_sessions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text,
`kind` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
`duration_ms` integer,
`notes` text
);
--> statement-breakpoint
INSERT INTO `__new_play_sessions`("id", "name", "kind", "status", "started_at", "ended_at", "duration_ms", "notes") SELECT "id", "name", "kind", "status", "started_at", "ended_at", "duration_ms", "notes" FROM `play_sessions`;--> statement-breakpoint
DROP TABLE `play_sessions`;--> statement-breakpoint
ALTER TABLE `__new_play_sessions` RENAME TO `play_sessions`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
@@ -0,0 +1,4 @@
ALTER TABLE `play_sessions` ADD `description` text;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `replayed_session_id` integer REFERENCES play_sessions(id) ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `play_count` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `last_played_at` integer;
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE `play_sessions` RENAME TO `sessions`;--> statement-breakpoint
ALTER TABLE `session_devices` RENAME COLUMN `play_session_id` TO `session_id`;--> statement-breakpoint
ALTER TABLE `session_events` RENAME COLUMN `play_session_id` TO `session_id`;--> statement-breakpoint
DROP INDEX `session_devices_play_session_id_idx`;--> statement-breakpoint
CREATE INDEX `session_devices_session_id_idx` ON `session_devices` (`session_id`);
+347
View File
@@ -0,0 +1,347 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dd3f46c7-f6a2-428b-b751-982018e83803",
"prevId": "cb592704-f845-4761-b5cc-53ec7cb5d179",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"play_sessions": {
"name": "play_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_devices_play_session_id_idx": {
"name": "session_devices_play_session_id_idx",
"columns": [
"play_session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_play_session_id_play_sessions_id_fk": {
"name": "session_devices_play_session_id_play_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"play_session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_play_session_id_play_sessions_id_fk": {
"name": "session_events_play_session_id_play_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d2afbfe4-99cd-4898-956d-2097b26dda60",
"prevId": "dd3f46c7-f6a2-428b-b751-982018e83803",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"play_sessions": {
"name": "play_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"replayed_session_id": {
"name": "replayed_session_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"play_sessions_replayed_session_id_play_sessions_id_fk": {
"name": "play_sessions_replayed_session_id_play_sessions_id_fk",
"tableFrom": "play_sessions",
"tableTo": "play_sessions",
"columnsFrom": [
"replayed_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_devices_play_session_id_idx": {
"name": "session_devices_play_session_id_idx",
"columns": [
"play_session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_play_session_id_play_sessions_id_fk": {
"name": "session_devices_play_session_id_play_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"play_session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_play_session_id_play_sessions_id_fk": {
"name": "session_events_play_session_id_play_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3d3770ee-d054-4dc7-9ff2-c033e8d000a1",
"prevId": "d2afbfe4-99cd-4898-956d-2097b26dda60",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_devices_session_id_idx": {
"name": "session_devices_session_id_idx",
"columns": [
"session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"session_devices_session_id_sessions_id_fk": {
"name": "session_devices_session_id_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_id_sessions_id_fk": {
"name": "session_events_session_id_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"replayed_session_id": {
"name": "replayed_session_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_replayed_session_id_sessions_id_fk": {
"name": "sessions_replayed_session_id_sessions_id_fk",
"tableFrom": "sessions",
"tableTo": "sessions",
"columnsFrom": [
"replayed_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+21
View File
@@ -8,6 +8,27 @@
"when": 1787617808883, "when": 1787617808883,
"tag": "0000_bouncy_anita_blake", "tag": "0000_bouncy_anita_blake",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1787856296915,
"tag": "0001_drop_recordings",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1787856317550,
"tag": "0002_add_session_replay_fields",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1787950004000,
"tag": "0003_rename_sessions",
"breakpoints": true
} }
] ]
} }
+3 -4
View File
@@ -1,5 +1,4 @@
import type { RecordingDeviceSlot } from "@/lib/db/schema"; import type { ConnectedDeviceInfo, ReplayDeviceSlot } from "./types";
import type { ConnectedDeviceInfo } from "./types";
export interface DeviceRemapEntry { export interface DeviceRemapEntry {
slotLabel: string; slotLabel: string;
@@ -8,14 +7,14 @@ export interface DeviceRemapEntry {
} }
/** /**
* Best-effort name match from a recording's saved device slots to the * Best-effort name match from a session's device slots to the
* currently-connected devices. Web Bluetooth exposes no stable hardware id * currently-connected devices. Web Bluetooth exposes no stable hardware id
* across sessions, so this is inherently approximate: two devices sharing an * across sessions, so this is inherently approximate: two devices sharing an
* identical advertised name are indistinguishable and must be disambiguated * identical advertised name are indistinguishable and must be disambiguated
* manually in the remap UI - this isn't a bug, it's a hard BLE limitation. * manually in the remap UI - this isn't a bug, it's a hard BLE limitation.
*/ */
export function autoMapDeviceSlots( export function autoMapDeviceSlots(
slots: RecordingDeviceSlot[], slots: ReplayDeviceSlot[],
connectedDevices: ConnectedDeviceInfo[], connectedDevices: ConnectedDeviceInfo[],
): DeviceRemapEntry[] { ): DeviceRemapEntry[] {
const usedIndexes = new Set<number>(); const usedIndexes = new Set<number>();
+12 -13
View File
@@ -13,21 +13,20 @@ interface ApiEvent {
/** /**
* Buffers every dispatched command (live or replay) and flushes it in * Buffers every dispatched command (live or replay) and flushes it in
* batches to the play-session's events endpoint, so a dragged slider never * batches to the session's events endpoint, so a dragged slider never
* fires one HTTP request per tick. Every command is always recorded here * fires one HTTP request per tick. Every command is always recorded here
* regardless of whether the session is later saved as a named recording - * regardless of whether the session is later named - any completed session
* "recording" is a save decision made after the fact, not a separate * can be replayed directly, there's no separate "save as recording" step.
* capture pipeline (see lib/db/queries/recordings.ts).
*/ */
class EventBuffer { class EventBuffer {
private buffer: CommandEvent[] = []; private buffer: CommandEvent[] = [];
private playSessionId: number | null = null; private sessionId: number | null = null;
private sessionStartedAt = 0; private sessionStartedAt = 0;
private sessionDeviceIdByDeviceIndex = new Map<number, number>(); private sessionDeviceIdByDeviceIndex = new Map<number, number>();
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
start(playSessionId: number, sessionStartedAt: number): void { start(sessionId: number, sessionStartedAt: number): void {
this.playSessionId = playSessionId; this.sessionId = sessionId;
this.sessionStartedAt = sessionStartedAt; this.sessionStartedAt = sessionStartedAt;
this.sessionDeviceIdByDeviceIndex = new Map(); this.sessionDeviceIdByDeviceIndex = new Map();
this.buffer = []; this.buffer = [];
@@ -46,16 +45,16 @@ class EventBuffer {
} }
record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void { record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void {
if (this.playSessionId === null) return; if (this.sessionId === null) return;
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt; const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
this.buffer.push({ ...event, tsMs }); this.buffer.push({ ...event, tsMs });
} }
async flush(): Promise<void> { async flush(): Promise<void> {
if (this.buffer.length === 0 || this.playSessionId === null) return; if (this.buffer.length === 0 || this.sessionId === null) return;
const events = this.drain(); const events = this.drain();
try { try {
await fetch(`/api/play-sessions/${this.playSessionId}/events`, { await fetch(`/api/sessions/${this.sessionId}/events`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }), body: JSON.stringify({ events }),
@@ -71,7 +70,7 @@ class EventBuffer {
void this.flush(); void this.flush();
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = null; this.timer = null;
this.playSessionId = null; this.sessionId = null;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", this.flushBeacon); window.removeEventListener("beforeunload", this.flushBeacon);
document.removeEventListener("visibilitychange", this.onVisibilityChange); document.removeEventListener("visibilitychange", this.onVisibilityChange);
@@ -83,10 +82,10 @@ class EventBuffer {
}; };
private flushBeacon = (): void => { private flushBeacon = (): void => {
if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return; if (this.buffer.length === 0 || this.sessionId === null || typeof navigator === "undefined") return;
const events = this.drain(); const events = this.drain();
navigator.sendBeacon( navigator.sendBeacon(
`/api/play-sessions/${this.playSessionId}/events`, `/api/sessions/${this.sessionId}/events`,
new Blob([JSON.stringify({ events })], { type: "application/json" }), new Blob([JSON.stringify({ events })], { type: "application/json" }),
); );
}; };
+11 -19
View File
@@ -1,9 +1,8 @@
import { getDevice } from "./client"; import { getDevice } from "./client";
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands"; import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
import { eventBuffer } from "./event-buffer";
import type { ActuatorInfo, CommandEvent } from "./types"; import type { ActuatorInfo, CommandEvent } from "./types";
export interface RecordingEventRow { export interface SessionEventRow {
tsMs: number; tsMs: number;
commandType: CommandEvent["commandType"]; commandType: CommandEvent["commandType"];
featureIndex: number; featureIndex: number;
@@ -13,13 +12,14 @@ export interface RecordingEventRow {
} }
export interface PlayerOptions { export interface PlayerOptions {
events: RecordingEventRow[]; events: SessionEventRow[];
/** The recording's actual duration (recordings.durationMs from the DB) - this is the source /** The source session's actual duration (sessions.duration_ms from the DB) - this is
* of truth for playback length, NOT the last event's timestamp: a recording can run for a * the source of truth for playback length, NOT the last event's timestamp: a session can run
* while after its last command (e.g. the user stopped the toy but let the session continue), * for a while after its last command (e.g. the user stopped the toy but let the session
* so deriving duration from events would end playback early and misreport it as "finished". */ * continue), so deriving duration from events would end playback early and misreport it as
* "finished". */
durationMs: number; durationMs: number;
/** Recording's session_device_id -> currently-connected device index, from the remap step. */ /** Source session's session_device_id -> currently-connected device index, from the remap step. */
sessionDeviceIdToDeviceIndex: Map<number, number>; sessionDeviceIdToDeviceIndex: Map<number, number>;
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>; actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
runtime: ButtplugRuntime; runtime: ButtplugRuntime;
@@ -30,11 +30,11 @@ export interface PlayerOptions {
} }
/** /**
* Replays a recording's events against the currently-connected devices, * Replays a session's events against the currently-connected devices,
* using performance.now()-relative scheduling (not wall-clock Date.now()) * using performance.now()-relative scheduling (not wall-clock Date.now())
* so long sessions don't accumulate drift from setTimeout jitter. * so long sessions don't accumulate drift from setTimeout jitter.
*/ */
export class RecordingPlayer { export class SessionPlayer {
private readonly durationMs: number; private readonly durationMs: number;
private startedAtPerf = 0; private startedAtPerf = 0;
private pausedAtMs = 0; private pausedAtMs = 0;
@@ -110,7 +110,7 @@ export class RecordingPlayer {
}, 200); }, 200);
} }
private async dispatch(event: RecordingEventRow): Promise<void> { private async dispatch(event: SessionEventRow): Promise<void> {
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId); const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
if (deviceIndex === undefined) return; if (deviceIndex === undefined) return;
@@ -130,14 +130,6 @@ export class RecordingPlayer {
const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined); const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
await feature.runOutput(cmd); await feature.runOutput(cmd);
} }
eventBuffer.record({
deviceIndex,
commandType: event.commandType,
featureIndex: event.featureIndex,
value: event.value,
durationMs: event.durationMs ?? undefined,
});
} catch (err) { } catch (err) {
this.options.onError?.(err instanceof Error ? err.message : String(err)); this.options.onError?.(err instanceof Error ? err.message : String(err));
} }
+17
View File
@@ -4,6 +4,8 @@
* rows) never has to import the client library directly. * rows) never has to import the client library directly.
*/ */
import type { DeviceCapabilities } from "@/lib/db/schema";
export type NormalizedOutputType = "vibrate" | "rotate" | "linear"; export type NormalizedOutputType = "vibrate" | "rotate" | "linear";
export type CommandType = NormalizedOutputType | "stop"; export type CommandType = NormalizedOutputType | "stop";
@@ -32,3 +34,18 @@ export interface CommandEvent {
value: number; value: number;
durationMs?: number; durationMs?: number;
} }
/**
* A device that took part in a session, as needed to remap it onto a
* currently-connected device for replay. Computed live from that session's
* session_devices/devices rows - not a frozen snapshot.
*/
export interface ReplayDeviceSlot {
slotLabel: string;
recordedBleName: string;
deviceClass: string | null;
capabilities: DeviceCapabilities | null;
/** The session_devices.id this slot was captured from - lets the replay UI
* map a chosen live device back to this slot's session_events. */
sourceSessionDeviceId: number;
}
-133
View File
@@ -1,133 +0,0 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, devices, recordings } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { incrementPlayCount } from "./recordings";
import type { DeviceCapabilities } from "@/lib/db/schema";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
export interface StartSessionDeviceInput {
slotLabel: string;
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}
export interface StartSessionInput {
kind: "live" | "replay";
replayedRecordingId?: number;
name?: string;
devices: StartSessionDeviceInput[];
}
export async function startPlaySession(input: StartSessionInput) {
const now = Date.now();
const [session] = await db
.insert(playSessions)
.values({
kind: input.kind,
replayedRecordingId: input.replayedRecordingId,
name: input.name,
status: "active",
startedAt: now,
})
.returning();
const sessionDeviceRows = [];
for (const d of input.devices) {
const device = await upsertDeviceByBleName({
bleName: d.bleName,
deviceClass: d.deviceClass,
capabilities: d.capabilities,
});
const [sessionDevice] = await db
.insert(sessionDevices)
.values({
playSessionId: session.id,
deviceId: device.id,
slotLabel: d.slotLabel,
connectedAt: now,
})
.returning();
sessionDeviceRows.push(sessionDevice);
}
if (input.kind === "replay" && input.replayedRecordingId) {
await incrementPlayCount(input.replayedRecordingId, now);
}
return { session, sessionDevices: sessionDeviceRows };
}
export async function endPlaySession(
id: number,
input: { status: "completed" | "aborted" },
) {
const [existing] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!existing) return undefined;
const endedAt = Date.now();
const durationMs = endedAt - existing.startedAt;
await db
.update(sessionDevices)
.set({ disconnectedAt: endedAt })
.where(eq(sessionDevices.playSessionId, id));
const [updated] = await db
.update(playSessions)
.set({ endedAt, durationMs, status: input.status })
.where(eq(playSessions.id, id))
.returning();
return updated;
}
export async function listPlaySessions() {
return db.select().from(playSessions).orderBy(playSessions.startedAt);
}
export async function listPlaySessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof playSessions.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(playSessions);
const items = await db
.select()
.from(playSessions)
.orderBy(desc(playSessions.startedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
/** Lightweight name-only lookup for page titles - avoids getPlaySessionDetail's device join. */
export async function getPlaySessionName(id: number): Promise<string | null | undefined> {
const [row] = await db.select({ name: playSessions.name }).from(playSessions).where(eq(playSessions.id, id));
return row?.name;
}
export async function getPlaySessionDetail(id: number) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!session) return undefined;
const sessionDeviceRows = await db
.select({
id: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
connectedAt: sessionDevices.connectedAt,
disconnectedAt: sessionDevices.disconnectedAt,
deviceId: devices.id,
deviceDisplayName: devices.displayName,
deviceBleName: devices.bleName,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, id));
return { session, devices: sessionDeviceRows };
}
export async function deletePlaySession(id: number, options?: { cascade?: boolean }) {
if (options?.cascade) {
await db.delete(recordings).where(eq(recordings.sourcePlaySessionId, id));
}
await db.delete(playSessions).where(eq(playSessions.id, id));
}
-111
View File
@@ -1,111 +0,0 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema";
import { getEventsForSession } from "./session-events";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
/**
* A recording is a thin pointer over an already-captured play_session, not a
* duplicated event pipeline: every live command is always persisted to
* session_events (stats need it regardless of whether the user saves a
* recording), so "saving a recording" just snapshots the session's device
* slots and points a new row at it. This keeps the replay-loader query and
* the stats-timeline query reading the exact same rows, with zero risk of
* live/recorded data drifting apart.
*/
export async function createRecording(input: {
sourcePlaySessionId: number;
name: string;
description?: string;
}) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, input.sourcePlaySessionId));
if (!session) throw new Error("source play session not found");
const sessionDeviceRows = await db
.select({
sessionDeviceId: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
bleName: devices.bleName,
deviceClass: devices.deviceClass,
capabilities: devices.capabilities,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, input.sourcePlaySessionId));
const deviceSlots: RecordingDeviceSlot[] = sessionDeviceRows.map((row) => ({
slotLabel: row.slotLabel,
recordedBleName: row.bleName,
deviceClass: row.deviceClass,
capabilities: row.capabilities,
sourceSessionDeviceId: row.sessionDeviceId,
}));
const [recording] = await db
.insert(recordings)
.values({
sourcePlaySessionId: input.sourcePlaySessionId,
name: input.name,
description: input.description,
createdAt: Date.now(),
durationMs: session.durationMs ?? 0,
deviceSlots,
playCount: 0,
})
.returning();
return recording;
}
export async function listRecordings() {
return db.select().from(recordings).orderBy(desc(recordings.createdAt));
}
export async function listRecordingsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof recordings.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(recordings);
const items = await db
.select()
.from(recordings)
.orderBy(desc(recordings.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
export async function getRecordingsForSession(playSessionId: number) {
return db
.select({ id: recordings.id, name: recordings.name })
.from(recordings)
.where(eq(recordings.sourcePlaySessionId, playSessionId));
}
/** Lightweight name-only lookup for page titles - avoids getRecording's event-table join. */
export async function getRecordingName(id: number): Promise<string | undefined> {
const [row] = await db.select({ name: recordings.name }).from(recordings).where(eq(recordings.id, id));
return row?.name;
}
export async function getRecording(id: number) {
const [recording] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!recording) return undefined;
const events = await getEventsForSession(recording.sourcePlaySessionId);
return { recording, events };
}
export async function renameRecording(id: number, input: { name?: string; description?: string }) {
const [updated] = await db.update(recordings).set(input).where(eq(recordings.id, id)).returning();
return updated;
}
export async function deleteRecording(id: number) {
await db.delete(recordings).where(eq(recordings.id, id));
}
export async function incrementPlayCount(id: number, playedAt: number): Promise<void> {
const [existing] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!existing) return;
await db
.update(recordings)
.set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt })
.where(eq(recordings.id, id));
}
+4 -4
View File
@@ -11,14 +11,14 @@ export interface IncomingEvent {
durationMs?: number; durationMs?: number;
} }
export async function insertEvents(playSessionId: number, events: IncomingEvent[]): Promise<void> { export async function insertEvents(sessionId: number, events: IncomingEvent[]): Promise<void> {
if (events.length === 0) return; if (events.length === 0) return;
const insertMany = sqlite.transaction((rows: IncomingEvent[]) => { const insertMany = sqlite.transaction((rows: IncomingEvent[]) => {
for (const row of rows) { for (const row of rows) {
db.insert(sessionEvents) db.insert(sessionEvents)
.values({ .values({
playSessionId, sessionId,
sessionDeviceId: row.sessionDeviceId, sessionDeviceId: row.sessionDeviceId,
tsMs: row.tsMs, tsMs: row.tsMs,
commandType: row.commandType, commandType: row.commandType,
@@ -33,10 +33,10 @@ export async function insertEvents(playSessionId: number, events: IncomingEvent[
insertMany(events); insertMany(events);
} }
export async function getEventsForSession(playSessionId: number) { export async function getEventsForSession(sessionId: number) {
return db return db
.select() .select()
.from(sessionEvents) .from(sessionEvents)
.where(eq(sessionEvents.playSessionId, playSessionId)) .where(eq(sessionEvents.sessionId, sessionId))
.orderBy(asc(sessionEvents.tsMs)); .orderBy(asc(sessionEvents.tsMs));
} }
+178
View File
@@ -0,0 +1,178 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, sessionDevices, devices } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { getEventsForSession } from "./session-events";
import type { DeviceCapabilities } from "@/lib/db/schema";
import type { ReplayDeviceSlot } from "@/lib/buttplug/types";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
export interface StartSessionDeviceInput {
slotLabel: string;
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}
export interface StartSessionInput {
name?: string;
devices: StartSessionDeviceInput[];
}
/**
* Starts a live control session. Replaying an existing session does NOT go
* through here - it just plays back the source session's already-recorded
* events against newly-mapped devices (see components/sessions/ReplayPlayer)
* and bumps the source's playCount/lastPlayedAt (incrementSessionPlayCount)
* without creating a session or duplicate events of its own.
*/
export async function startSession(input: StartSessionInput) {
const now = Date.now();
const [session] = await db
.insert(sessions)
.values({
kind: "live",
name: input.name,
status: "active",
startedAt: now,
})
.returning();
const sessionDeviceRows = [];
for (const d of input.devices) {
const device = await upsertDeviceByBleName({
bleName: d.bleName,
deviceClass: d.deviceClass,
capabilities: d.capabilities,
});
const [sessionDevice] = await db
.insert(sessionDevices)
.values({
sessionId: session.id,
deviceId: device.id,
slotLabel: d.slotLabel,
connectedAt: now,
})
.returning();
sessionDeviceRows.push(sessionDevice);
}
return { session, sessionDevices: sessionDeviceRows };
}
export async function endSession(
id: number,
input: { status: "completed" | "aborted" },
) {
const [existing] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!existing) return undefined;
const endedAt = Date.now();
const durationMs = endedAt - existing.startedAt;
await db
.update(sessionDevices)
.set({ disconnectedAt: endedAt })
.where(eq(sessionDevices.sessionId, id));
const [updated] = await db
.update(sessions)
.set({ endedAt, durationMs, status: input.status })
.where(eq(sessions.id, id))
.returning();
return updated;
}
export async function listSessions() {
return db.select().from(sessions).orderBy(sessions.startedAt);
}
export async function listSessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof sessions.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(sessions);
const items = await db
.select()
.from(sessions)
.orderBy(desc(sessions.startedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
/** Lightweight name-only lookup for page titles - avoids getSessionDetail's device join. */
export async function getSessionName(id: number): Promise<string | null | undefined> {
const [row] = await db.select({ name: sessions.name }).from(sessions).where(eq(sessions.id, id));
return row?.name;
}
export async function getSessionDetail(id: number) {
const [session] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!session) return undefined;
const sessionDeviceRows = await db
.select({
id: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
connectedAt: sessionDevices.connectedAt,
disconnectedAt: sessionDevices.disconnectedAt,
deviceId: devices.id,
deviceDisplayName: devices.displayName,
deviceBleName: devices.bleName,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.sessionId, id));
const replayedFromName = session.replayedSessionId ? await getSessionName(session.replayedSessionId) : undefined;
return { session, devices: sessionDeviceRows, replayedFromName };
}
/** Snapshot of a session's devices, shaped for the replay device-remap UI - computed live
* from session_devices/devices, not a frozen copy (see ReplayDeviceSlot). */
export async function getSessionForReplay(id: number) {
const [session] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!session) return undefined;
const deviceSlotRows = await db
.select({
sessionDeviceId: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
bleName: devices.bleName,
deviceClass: devices.deviceClass,
capabilities: devices.capabilities,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.sessionId, id));
const deviceSlots: ReplayDeviceSlot[] = deviceSlotRows.map((row) => ({
slotLabel: row.slotLabel,
recordedBleName: row.bleName,
deviceClass: row.deviceClass,
capabilities: row.capabilities,
sourceSessionDeviceId: row.sessionDeviceId,
}));
const events = await getEventsForSession(id);
return { session, deviceSlots, events };
}
export async function renameSession(id: number, input: { name?: string; description?: string }) {
const [updated] = await db.update(sessions).set(input).where(eq(sessions.id, id)).returning();
return updated;
}
export async function incrementSessionPlayCount(id: number, playedAt: number): Promise<void> {
const [existing] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!existing) return;
await db
.update(sessions)
.set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt })
.where(eq(sessions.id, id));
}
export async function deleteSession(id: number) {
await db.delete(sessions).where(eq(sessions.id, id));
}
+12 -53
View File
@@ -1,42 +1,24 @@
import { sql, eq, and, ne } from "drizzle-orm"; import { sql, eq, and, ne } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, sessionEvents, devices, recordings } from "@/lib/db/schema"; import { sessions, sessionDevices, sessionEvents, devices } from "@/lib/db/schema";
export async function getSessionsSummary() { export async function getSessionsSummary() {
const [totals] = await db const [totals] = await db
.select({ .select({
count: sql<number>`count(*)`, count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`, totalDurationMs: sql<number>`coalesce(sum(${sessions.durationMs}), 0)`,
avgDurationMs: sql<number>`coalesce(avg(${playSessions.durationMs}), 0)`, avgDurationMs: sql<number>`coalesce(avg(${sessions.durationMs}), 0)`,
// How many times sessions have been replayed - replaying itself creates no
// session of its own, it just bumps the source session's playCount.
totalReplays: sql<number>`coalesce(sum(${sessions.playCount}), 0)`,
}) })
.from(playSessions) .from(sessions)
.where(eq(playSessions.status, "completed")); .where(eq(sessions.status, "completed"));
const byKind = await db return totals;
.select({
kind: playSessions.kind,
count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`,
})
.from(playSessions)
.where(eq(playSessions.status, "completed"))
.groupBy(playSessions.kind);
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, byKind, durationPerDevice };
} }
export async function getSessionTimeline(playSessionId: number, bucketMs = 1000) { export async function getSessionTimeline(sessionId: number, bucketMs = 1000) {
// Reuse the same expression object (not a `sql`bucket`` alias reference) // Reuse the same expression object (not a `sql`bucket`` alias reference)
// in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that // in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that
// SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier // SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier
@@ -53,7 +35,7 @@ export async function getSessionTimeline(playSessionId: number, bucketMs = 1000)
}) })
.from(sessionEvents) .from(sessionEvents)
.innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.where(and(eq(sessionEvents.playSessionId, playSessionId), ne(sessionEvents.commandType, "stop"))) .where(and(eq(sessionEvents.sessionId, sessionId), ne(sessionEvents.commandType, "stop")))
.groupBy(bucket, sessionEvents.sessionDeviceId) .groupBy(bucket, sessionEvents.sessionDeviceId)
.orderBy(bucket); .orderBy(bucket);
} }
@@ -64,7 +46,7 @@ export async function getDeviceUsageStats() {
deviceId: devices.id, deviceId: devices.id,
displayName: devices.displayName, displayName: devices.displayName,
bleName: devices.bleName, bleName: devices.bleName,
sessionCount: sql<number>`count(distinct ${sessionDevices.playSessionId})`, sessionCount: sql<number>`count(distinct ${sessionDevices.sessionId})`,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`, totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
lastUsedAt: sql<number | null>`max(${sessionDevices.connectedAt})`, lastUsedAt: sql<number | null>`max(${sessionDevices.connectedAt})`,
}) })
@@ -84,26 +66,3 @@ export async function getDeviceCommandCounts() {
.leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.groupBy(devices.id); .groupBy(devices.id);
} }
export async function getRecordingLibraryStats() {
const [totals] = await db
.select({
count: sql<number>`count(*)`,
avgDurationMs: sql<number>`coalesce(avg(${recordings.durationMs}), 0)`,
totalPlayCount: sql<number>`coalesce(sum(${recordings.playCount}), 0)`,
})
.from(recordings);
const list = await db
.select({
id: recordings.id,
name: recordings.name,
durationMs: recordings.durationMs,
playCount: recordings.playCount,
lastPlayedAt: recordings.lastPlayedAt,
})
.from(recordings)
.orderBy(sql`${recordings.playCount} desc`);
return { ...totals, list };
}
+15 -34
View File
@@ -21,11 +21,13 @@ export const devices = sqliteTable("devices", {
lastConnectedAt: integer("last_connected_at"), lastConnectedAt: integer("last_connected_at"),
}); });
export const playSessions = sqliteTable("play_sessions", { export const sessions = sqliteTable("sessions", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name"), name: text("name"),
description: text("description"),
kind: text("kind", { enum: ["live", "replay"] }).notNull(), kind: text("kind", { enum: ["live", "replay"] }).notNull(),
replayedRecordingId: integer("replayed_recording_id").references((): AnySQLiteColumn => recordings.id, { // Self-referencing: which session's events this session replayed, if any.
replayedSessionId: integer("replayed_session_id").references((): AnySQLiteColumn => sessions.id, {
onDelete: "set null", onDelete: "set null",
}), }),
status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"), status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"),
@@ -33,15 +35,20 @@ export const playSessions = sqliteTable("play_sessions", {
endedAt: integer("ended_at"), endedAt: integer("ended_at"),
durationMs: integer("duration_ms"), durationMs: integer("duration_ms"),
notes: text("notes"), notes: text("notes"),
// How many times (and when) this session's events have been replayed -
// any completed session can be a replay source, there's no separate
// "saved recording" concept anymore.
playCount: integer("play_count").notNull().default(0),
lastPlayedAt: integer("last_played_at"),
}); });
export const sessionDevices = sqliteTable( export const sessionDevices = sqliteTable(
"session_devices", "session_devices",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id") sessionId: integer("session_id")
.notNull() .notNull()
.references(() => playSessions.id, { onDelete: "cascade" }), .references(() => sessions.id, { onDelete: "cascade" }),
deviceId: integer("device_id") deviceId: integer("device_id")
.notNull() .notNull()
.references(() => devices.id, { onDelete: "restrict" }), .references(() => devices.id, { onDelete: "restrict" }),
@@ -49,16 +56,16 @@ export const sessionDevices = sqliteTable(
connectedAt: integer("connected_at").notNull(), connectedAt: integer("connected_at").notNull(),
disconnectedAt: integer("disconnected_at"), disconnectedAt: integer("disconnected_at"),
}, },
(table) => [index("session_devices_play_session_id_idx").on(table.playSessionId)], (table) => [index("session_devices_session_id_idx").on(table.sessionId)],
); );
export const sessionEvents = sqliteTable( export const sessionEvents = sqliteTable(
"session_events", "session_events",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id") sessionId: integer("session_id")
.notNull() .notNull()
.references(() => playSessions.id, { onDelete: "cascade" }), .references(() => sessions.id, { onDelete: "cascade" }),
sessionDeviceId: integer("session_device_id") sessionDeviceId: integer("session_device_id")
.notNull() .notNull()
.references(() => sessionDevices.id, { onDelete: "cascade" }), .references(() => sessionDevices.id, { onDelete: "cascade" }),
@@ -72,33 +79,7 @@ export const sessionEvents = sqliteTable(
rawPayload: text("raw_payload", { mode: "json" }), rawPayload: text("raw_payload", { mode: "json" }),
}, },
(table) => [ (table) => [
index("session_events_session_ts_idx").on(table.playSessionId, table.tsMs), index("session_events_session_ts_idx").on(table.sessionId, table.tsMs),
index("session_events_session_device_idx").on(table.sessionDeviceId), index("session_events_session_device_idx").on(table.sessionDeviceId),
], ],
); );
export interface RecordingDeviceSlot {
slotLabel: string;
recordedBleName: string;
deviceClass: string | null;
capabilities: DeviceCapabilities | null;
/** The original session_devices.id this slot was captured from - lets the
* replay UI map a chosen live device back to this slot's session_events. */
sourceSessionDeviceId: number;
}
export const recordings = sqliteTable("recordings", {
id: integer("id").primaryKey({ autoIncrement: true }),
// A recording is a thin pointer over an already-captured play_session, not
// a duplicated event pipeline - see lib/db/queries/recordings.ts for why.
sourcePlaySessionId: integer("source_play_session_id")
.notNull()
.references(() => playSessions.id, { onDelete: "restrict" }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("created_at").notNull(),
durationMs: integer("duration_ms").notNull(),
deviceSlots: text("device_slots", { mode: "json" }).$type<RecordingDeviceSlot[]>().notNull(),
playCount: integer("play_count").notNull().default(0),
lastPlayedAt: integer("last_played_at"),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.4.0", "version": "0.9.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",