36 Commits
Author SHA1 Message Date
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
valknarandClaude Sonnet 5 9e0380ec75 Add consistent API request logging and a themed 404 page, bump to 0.4.0
CI / Build and push image (push) Successful in 1m10s
CI / Static checks (push) Successful in 1m39s
Every app/api route handler now goes through withRouteLogging, which logs
a correlated reqId/method/path/status/duration for each request and turns
any uncaught error into a logged stack trace plus a clean JSON 500 instead
of Next's default opaque failure. proxy.ts logs rejected auth attempts, and
instrumentation.ts's onRequestError catches anything that still escapes a
route handler. Also adds a minimal not-found page matching the app's card
styling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 08:45:15 +02:00
valknarandClaude Sonnet 5 5484d3cefe Fix device/replay/session UX issues, add pagination, bump to 0.3.0
CI / Build and push image (push) Successful in 1m2s
CI / Static checks (push) Successful in 1m7s
- Fix device rename input collapsing on mobile (fixed width vs w-full
  inside an auto-layout table column).
- Add cascade-delete confirmation for sessions with a saved recording.
- Fix header connection LED: derive state from scanning/device-count/
  recording instead of the Buttplug client's raw connected flag; drop
  the label text and hide the indicator entirely when idle.
- Add a per-device disconnect button (stop + remove from store, the
  closest equivalent Buttplug's protocol allows per device).
- Fix replay ending early: duration now comes from the recording's
  actual durationMs, not the last event's timestamp.
- Replay robustness: show which devices are being replayed to, reset
  actuators to zero on start/play/pause, fully disconnect devices and
  the whole client on stop/unmount, surface command failures via toast.
- Stop flagging the header LED red during replay - recording is only
  for live sessions.
- Add page-number pagination to recordings/sessions/devices lists.
- Wordmark SEXY -> Sexy; add proper per-page <title>s including
  dynamic titles for recording/session detail and replay pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:26:18 +02:00
valknarandClaude Sonnet 5 f97ed6da04 Fix session timeline query, bump to 0.2.1
CI / Static checks (push) Successful in 1m3s
CI / Build and push image (push) Successful in 1m5s
getSessionTimeline() grouped/ordered by a bare `bucket` identifier,
assuming SQLite would resolve it against the SELECT list's alias -
it doesn't in this generated query, so every /sessions/:id page and
/api/stats/sessions/:id/timeline request failed with "no such column:
bucket". Fixed by reusing the actual bucket expression object in
groupBy/orderBy instead of referencing it by name. Verified against a
seeded session - previously every request, now correct bucketed data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
2026-08-25 17:41:43 +02:00
valknarandClaude Sonnet 5 0bd36b08c9 Upgrade to buttplug v5, add battery level display, bump to 0.2.0
CI / Static checks (push) Successful in 1m0s
CI / Build and push image (push) Successful in 1m3s
buttplug-wasm@3.0.0 still declares buttplug@^4.0.2 as its dependency,
and v5 changed the OutputCmd wire shape (Value: number[] -> number) -
a real protocol difference, not just a type mismatch. Confirmed by
testing against real Lovense hardware that device control still works
in practice, so the bump stands; the risk is documented at the
client.connect() cast in case a future device/build doesn't fare as
well.

Also fixes a Map vs ReadonlyMap mismatch in the version-agnostic
feature-type extraction (v4 returns Map, v5 returns ReadonlyMap) that
surfaced while making this change, and adds a battery indicator to each
connected device's card - reads once on connect, refreshes every 60s,
using device.hasInput("Battery") to detect support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
2026-08-25 17:33:48 +02:00
87 changed files with 3110 additions and 1322 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
+3
View File
@@ -1,5 +1,8 @@
import type { Metadata } from "next";
import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader"; import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader";
export const metadata: Metadata = { title: "Control" };
export default function ControlPage() { export default function ControlPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
+14 -4
View File
@@ -1,11 +1,20 @@
import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DevicesTable } from "@/components/devices/DevicesTable"; import { DevicesTable } from "@/components/devices/DevicesTable";
import { listDevices } from "@/lib/db/queries/devices"; import { PageNav } from "@/components/shared/PageNav";
import { listDevicesPage } from "@/lib/db/queries/devices";
import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Devices" };
export default async function DevicesPage() { export default async function DevicesPage({
const devices = await listDevices(); searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const page = parsePage((await searchParams).page);
const { items: devices, pageSize, total } = await listDevicesPage(page);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -17,8 +26,9 @@ export default async function DevicesPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Known devices</CardTitle> <CardTitle className="text-base">Known devices</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col gap-3">
<DevicesTable devices={devices} /> <DevicesTable devices={devices} />
<PageNav basePath="/devices" page={page} pageSize={pageSize} total={total} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+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>
); );
} }
-74
View File
@@ -1,74 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getRecording } from "@/lib/db/queries/recordings";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic";
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>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader";
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>
);
}
-26
View File
@@ -1,26 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecordingsTable } from "@/components/recordings/RecordingsTable";
import { listRecordings } from "@/lib/db/queries/recordings";
export const dynamic = "force-dynamic";
export default async function RecordingsPage() {
const recordings = await listRecordings();
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>
<RecordingsTable recordings={recordings} />
</CardContent>
</Card>
</div>
);
}
+50 -7
View File
@@ -1,11 +1,26 @@
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { getPlaySessionDetail } 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> {
const { id } = await params;
const name = await getSessionName(Number(id));
return { title: name ?? `Session #${id}` };
}
function formatDuration(ms: number | null): string { function formatDuration(ms: number | null): string {
if (ms === null) return "-"; if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000); const totalSeconds = Math.round(ms / 1000);
@@ -15,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>
);
}
+14 -4
View File
@@ -1,11 +1,20 @@
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 { listPlaySessions } from "@/lib/db/queries/play-sessions"; import { PageNav } from "@/components/shared/PageNav";
import { listSessionsPage } from "@/lib/db/queries/sessions";
import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Sessions" };
export default async function SessionsPage() { export default async function SessionsPage({
const sessions = [...(await listPlaySessions())].reverse(); searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const page = parsePage((await searchParams).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">
@@ -17,8 +26,9 @@ export default async function SessionsPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">History</CardTitle> <CardTitle className="text-base">History</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col gap-3">
<SessionsTable sessions={sessions} /> <SessionsTable sessions={sessions} />
<PageNav basePath="/sessions" page={page} pageSize={pageSize} total={total} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+7 -22
View File
@@ -1,17 +1,16 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Metadata } from "next";
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 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]));
@@ -21,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>
); );
} }
+7 -2
View File
@@ -3,21 +3,26 @@ import { z } from "zod";
import { getEnv } from "@/lib/env"; import { getEnv } from "@/lib/env";
import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare"; import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare";
import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session"; import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session";
import { withRouteLogging } from "@/lib/api/with-route-logging";
import { createLogger } from "@/lib/logger";
const log = createLogger("auth");
const bodySchema = z.object({ secret: z.string().min(1) }); const bodySchema = z.object({ secret: z.string().min(1) });
export async function POST(req: Request) { export const POST = withRouteLogging("auth.login", 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: "secret is required" }, { status: 400 }); return NextResponse.json({ error: "secret is required" }, { status: 400 });
} }
if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) { if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) {
log.warn("login attempt with invalid secret");
return NextResponse.json({ error: "invalid secret" }, { status: 401 }); return NextResponse.json({ error: "invalid secret" }, { status: 401 });
} }
log.info("login succeeded");
const token = await createSessionToken(); const token = await createSessionToken();
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.set({ ...sessionCookieOptions, value: token }); res.cookies.set({ ...sessionCookieOptions, value: token });
return res; return res;
} });
+3 -2
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export async function POST() { export const POST = withRouteLogging("auth.logout", async () => {
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.delete(SESSION_COOKIE_NAME); res.cookies.delete(SESSION_COOKIE_NAME);
return res; return res;
} });
+14 -10
View File
@@ -1,16 +1,20 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { renameDevice } from "@/lib/db/queries/devices"; import { renameDevice } from "@/lib/db/queries/devices";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const bodySchema = z.object({ displayName: z.string().min(1).max(120) }); const bodySchema = z.object({ displayName: z.string().min(1).max(120) });
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { export const PATCH = withRouteLogging(
const { id } = await params; "devices.rename",
const parsed = bodySchema.safeParse(await req.json().catch(() => null)); async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
if (!parsed.success) { const { id } = await params;
return NextResponse.json({ error: "displayName is required" }, { status: 400 }); const parsed = bodySchema.safeParse(await req.json().catch(() => null));
} if (!parsed.success) {
const updated = await renameDevice(Number(id), parsed.data.displayName); return NextResponse.json({ error: "displayName is required" }, { status: 400 });
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); }
return NextResponse.json({ device: updated }); const updated = await renameDevice(Number(id), parsed.data.displayName);
} if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ device: updated });
},
);
+3 -2
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { listDevices } from "@/lib/db/queries/devices"; import { listDevices } from "@/lib/db/queries/devices";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export async function GET() { export const GET = withRouteLogging("devices.list", async () => {
const rows = await listDevices(); const rows = await listDevices();
return NextResponse.json({ devices: rows }); return NextResponse.json({ devices: rows });
} });
+8 -3
View File
@@ -1,11 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sqlite } from "@/lib/db/client"; import { sqlite } from "@/lib/db/client";
import { withRouteLogging } from "@/lib/api/with-route-logging";
import { createLogger } from "@/lib/logger";
export async function GET() { const log = createLogger("health");
export const GET = withRouteLogging("health.check", async () => {
try { try {
sqlite.prepare("select 1").get(); sqlite.prepare("select 1").get();
return NextResponse.json({ status: "ok" }); return NextResponse.json({ status: "ok" });
} catch { } catch (err) {
log.error("health check failed", { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ status: "error" }, { status: 500 }); return NextResponse.json({ status: "error" }, { status: 500 });
} }
} });
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { insertEvents } from "@/lib/db/queries/session-events";
const eventSchema = z.object({
sessionDeviceId: z.number().int().positive(),
tsMs: z.number().int().min(0),
commandType: z.enum(["vibrate", "rotate", "linear", "stop"]),
featureIndex: z.number().int().min(0),
value: z.number().min(0).max(1),
durationMs: z.number().int().positive().optional(),
});
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
await insertEvents(Number(id), parsed.data.events);
return NextResponse.json({ inserted: parsed.data.events.length });
}
-40
View File
@@ -1,40 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
export async function GET(_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 async function PATCH(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 async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
await deletePlaySession(Number(id));
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")) {
return NextResponse.json(
{ error: "cannot delete a session that a saved recording still references" },
{ status: 409 },
);
}
throw err;
}
}
-32
View File
@@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
const patchSchema = z.object({
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
});
export async function GET(_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 async function PATCH(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 async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteRecording(Number(id));
return NextResponse.json({ ok: true });
}
-23
View File
@@ -1,23 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
const bodySchema = z.object({
sourcePlaySessionId: z.number().int().positive(),
name: z.string().min(1).max(160),
description: z.string().max(2000).optional(),
});
export async function GET() {
const rows = await listRecordings();
return NextResponse.json({ recordings: rows });
}
export async function POST(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 });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { insertEvents } from "@/lib/db/queries/session-events";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const eventSchema = z.object({
sessionDeviceId: z.number().int().positive(),
tsMs: z.number().int().min(0),
commandType: z.enum(["vibrate", "rotate", "linear", "stop"]),
featureIndex: z.number().int().min(0),
value: z.number().min(0).max(1),
durationMs: z.number().int().positive().optional(),
});
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export const POST = withRouteLogging(
"sessions.events.append",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
await insertEvents(Number(id), parsed.data.events);
return NextResponse.json({ inserted: parsed.data.events.length });
},
);
+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,7 @@
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";
const deviceInputSchema = z.object({ const deviceInputSchema = z.object({
slotLabel: z.string().min(1), slotLabel: z.string().min(1),
@@ -10,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 async function GET() { 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 async function POST(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 });
} });
+3 -2
View File
@@ -1,9 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats"; import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export async function GET() { export const GET = withRouteLogging("stats.devices", async () => {
const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]); const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 })); const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 }));
return NextResponse.json({ devices }); return NextResponse.json({ devices });
} });
-7
View File
@@ -1,7 +0,0 @@
import { NextResponse } from "next/server";
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
export async function GET() {
const stats = await getRecordingLibraryStats();
return NextResponse.json(stats);
}
+10 -6
View File
@@ -1,9 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionTimeline } from "@/lib/db/queries/stats"; import { getSessionTimeline } from "@/lib/db/queries/stats";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { export const GET = withRouteLogging(
const { id } = await params; "stats.session-timeline",
const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const timeline = await getSessionTimeline(Number(id), bucketMs); const { id } = await params;
return NextResponse.json({ timeline }); const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000);
} const timeline = await getSessionTimeline(Number(id), bucketMs);
return NextResponse.json({ timeline });
},
);
+3 -2
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionsSummary } from "@/lib/db/queries/stats"; import { getSessionsSummary } from "@/lib/db/queries/stats";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export async function GET() { export const GET = withRouteLogging("stats.sessions", async () => {
const summary = await getSessionsSummary(); const summary = await getSessionsSummary();
return NextResponse.json(summary); return NextResponse.json(summary);
} });
+101 -7
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,17 +180,20 @@
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, with a hairline border and soft inset top highlight. */
.bp-glass { .bp-glass {
background: var(--card); background: color-mix(in srgb, var(--card) 55%, transparent);
backdrop-filter: blur(20px) saturate(150%);
border: 1px solid var(--border); border: 1px solid var(--border);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent);
transition: transition:
border-color 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease; box-shadow 0.2s ease,
background-color 0.2s ease;
} }
.bp-glass:hover { .bp-glass:hover {
background: color-mix(in srgb, var(--card) 62%, transparent);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--foreground) 10%, transparent), inset 0 1px 0 color-mix(in srgb, var(--foreground) 10%, transparent),
@@ -176,6 +212,17 @@
.bp-led[data-on="true"] { .bp-led[data-on="true"] {
background: var(--primary); background: var(--primary);
} }
/* Header connection indicator: state-driven color, distinct from the
transmit-dot's boolean data-on above. */
.bp-led[data-state="scanning"] {
background: var(--muted-foreground);
}
.bp-led[data-state="connected"] {
background: var(--primary);
}
.bp-led[data-state="recording"] {
background: var(--destructive);
}
/* Thin panel seam - a hardware "trim line," not a plain UI divider. */ /* Thin panel seam - a hardware "trim line," not a plain UI divider. */
.bp-hairline { .bp-hairline {
@@ -190,18 +237,65 @@
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% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent); box-shadow: 0 0 0 0 color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 55%, transparent);
} }
50% { 50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--bp-pulse-color, var(--primary)) 0%, transparent);
} }
} }
.bp-pulse { .bp-pulse {
animation: bp-pulse-glow 2s ease-in-out infinite; animation: bp-pulse-glow 2s ease-in-out infinite;
} }
.bp-led[data-state="scanning"] {
--bp-pulse-color: var(--muted-foreground);
}
.bp-led[data-state="recording"] {
--bp-pulse-color: var(--destructive);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.bp-pulse { .bp-pulse {
+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

+4 -4
View File
@@ -1,16 +1,16 @@
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 = {
title: "SEXY", title: { default: "Sexy", template: "%s · Sexy" },
description: "Bluetooth toy control console", description: "Bluetooth toy control console",
}; };
@@ -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>
+4 -1
View File
@@ -1,8 +1,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import type { Metadata } from "next";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "@/components/auth/LoginForm"; import { LoginForm } from "@/components/auth/LoginForm";
import { BrandMark } from "@/components/layout/BrandMark"; import { BrandMark } from "@/components/layout/BrandMark";
export const metadata: Metadata = { title: "Log in" };
export default function LoginPage() { export default function LoginPage() {
return ( return (
<div className="flex min-h-dvh items-center justify-center p-4"> <div className="flex min-h-dvh items-center justify-center p-4">
@@ -10,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>
+2 -2
View File
@@ -2,8 +2,8 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "SEXY", name: "Sexy",
short_name: "SEXY", short_name: "Sexy",
description: "Bluetooth toy control console", description: "Bluetooth toy control console",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark";
export const metadata: Metadata = { title: "Not found" };
export default function NotFound() {
return (
<div className="flex min-h-dvh items-center justify-center p-4">
<Card className="bp-glass w-full max-w-sm">
<CardContent className="flex flex-col items-center gap-4 py-4 text-center">
<BrandMark size={32} />
<div className="space-y-1">
<p className="bp-readout bp-gradient-text text-4xl font-semibold">404</p>
<p className="text-sm text-muted-foreground">This page doesn&apos;t exist.</p>
</div>
<Button asChild size="sm">
<Link href="/">Back to dashboard</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
+51 -11
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
disconnectAll, disconnectAll,
getBatteryLevel,
getButtplugClientHandle, getButtplugClientHandle,
getDevice, getDevice,
isWebBluetoothSupported, isWebBluetoothSupported,
@@ -19,10 +20,10 @@ 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";
interface ActivePlaySession { interface ActiveSession {
id: number; id: number;
startedAt: number; startedAt: number;
sessionDeviceIdByDeviceIndex: Map<number, number>; sessionDeviceIdByDeviceIndex: Map<number, number>;
@@ -42,9 +43,13 @@ export function ButtplugConsole() {
const devices = useButtplugStore((s) => s.devices); const devices = useButtplugStore((s) => s.devices);
const actuatorValues = useButtplugStore((s) => s.actuatorValues); const actuatorValues = useButtplugStore((s) => s.actuatorValues);
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const batteryLevels = useButtplugStore((s) => s.batteryLevels);
const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel);
const removeDevice = useButtplugStore((s) => s.removeDevice);
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);
@@ -64,6 +69,21 @@ export function ButtplugConsole() {
}; };
}, []); }, []);
useEffect(() => {
const devicesWithBattery = Object.values(devices).filter((d) => d.hasBattery);
if (devicesWithBattery.length === 0) return;
function refresh() {
for (const device of devicesWithBattery) {
void getBatteryLevel(device.index).then((level) => setBatteryLevel(device.index, level));
}
}
refresh();
const interval = setInterval(refresh, 60_000);
return () => clearInterval(interval);
}, [devices, setBatteryLevel]);
async function ensureRuntime(): Promise<ButtplugRuntime> { async function ensureRuntime(): Promise<ButtplugRuntime> {
if (runtimeRef.current) return runtimeRef.current; if (runtimeRef.current) return runtimeRef.current;
const { runtime } = await getButtplugClientHandle(); const { runtime } = await getButtplugClientHandle();
@@ -85,14 +105,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),
@@ -118,6 +137,7 @@ export function ButtplugConsole() {
sessionDeviceIdByDeviceIndex, sessionDeviceIdByDeviceIndex,
}); });
setElapsedMs(0); setElapsedMs(0);
setRecording(true);
} catch { } catch {
toast.error("Could not start session."); toast.error("Could not start session.");
} finally { } finally {
@@ -129,7 +149,8 @@ export function ButtplugConsole() {
if (!activeSession) return; if (!activeSession) return;
setSessionBusy(true); setSessionBusy(true);
eventBuffer.stop(); eventBuffer.stop();
await fetch(`/api/play-sessions/${activeSession.id}`, { setRecording(false);
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" }),
@@ -153,11 +174,17 @@ export function ButtplugConsole() {
const runtime = await ensureRuntime(); const runtime = await ensureRuntime();
const liveDevice = await getDevice(device.index); const liveDevice = await getDevice(device.index);
const feature = liveDevice && findFeature(liveDevice, featureIndex); const feature = liveDevice && findFeature(liveDevice, featureIndex);
if (!liveDevice || !feature) return; if (!liveDevice || !feature) {
toast.error(`${device.name}: device not found - try reconnecting.`);
return;
}
setTransmittingKey(key); setTransmittingKey(key);
const cmd = buildOutputCommand(runtime, actuator, v); const cmd = buildOutputCommand(runtime, actuator, v);
await feature.runOutput(cmd); await feature.runOutput(cmd);
eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v }); eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(`${device.name}: command failed - ${message}`);
} finally { } finally {
setTransmittingKey((k) => (k === key ? null : k)); setTransmittingKey((k) => (k === key ? null : k));
} }
@@ -173,6 +200,17 @@ export function ButtplugConsole() {
device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0)); device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0));
} }
async function handleDisconnectDevice(device: ConnectedDeviceInfo) {
// Buttplug's protocol has no per-device disconnect message - only a
// whole-client disconnect() and stop() (halts actuators). Stopping it and
// removing it from local state is the closest equivalent: the card
// disappears and it stops receiving commands, though the underlying BLE
// link may persist until the whole client disconnects.
const liveDevice = await getDevice(device.index);
await liveDevice?.stop().catch(() => {});
removeDevice(device.index);
}
if (!supported) { if (!supported) {
return ( return (
<Card className="bp-glass"> <Card className="bp-glass">
@@ -187,7 +225,7 @@ export function ButtplugConsole() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center gap-4">
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} /> <DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
<RecordControls <RecordControls
active={activeSession !== null} active={activeSession !== null}
@@ -203,7 +241,7 @@ export function ButtplugConsole() {
{Object.keys(devices).length === 0 ? ( {Object.keys(devices).length === 0 ? (
<Card className="bp-glass"> <Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground"> <CardContent className="text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s
pairing prompt. pairing prompt.
</CardContent> </CardContent>
@@ -214,6 +252,7 @@ export function ButtplugConsole() {
<DeviceCard <DeviceCard
key={device.index} key={device.index}
device={device} device={device}
batteryLevel={batteryLevels[device.index] ?? null}
actuatorValues={Object.fromEntries( actuatorValues={Object.fromEntries(
device.actuators.map((a) => [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]), device.actuators.map((a) => [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]),
)} )}
@@ -224,13 +263,14 @@ export function ButtplugConsole() {
} }
onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)} onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)}
onStop={() => void handleStopDevice(device)} onStop={() => void handleStopDevice(device)}
onDisconnect={() => void handleDisconnectDevice(device)}
/> />
))} ))}
</div> </div>
)} )}
<SaveRecordingDialog <NameSessionDialog
playSessionId={savePromptSessionId} sessionId={savePromptSessionId}
onClose={() => setSavePromptSessionId(null)} onClose={() => setSavePromptSessionId(null)}
onSaved={() => setSavePromptSessionId(null)} onSaved={() => setSavePromptSessionId(null)}
/> />
+31 -5
View File
@@ -4,29 +4,55 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider"; import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning, Unlink } from "lucide-react";
interface DeviceCardProps { interface DeviceCardProps {
device: ConnectedDeviceInfo; device: ConnectedDeviceInfo;
batteryLevel: number | null;
actuatorValues: Record<number, number>; actuatorValues: Record<number, number>;
transmittingFeatureIndex: number | null; transmittingFeatureIndex: number | null;
onActuatorChange: (featureIndex: number, value: number) => void; onActuatorChange: (featureIndex: number, value: number) => void;
onStop: () => void; onStop: () => void;
onDisconnect: () => void;
}
function BatteryIndicator({ level }: { level: number }) {
const Icon = level < 0.15 ? BatteryWarning : level < 0.4 ? BatteryLow : level < 0.8 ? BatteryMedium : BatteryFull;
return (
<span className={`flex items-center gap-1 ${level < 0.15 ? "text-destructive" : "text-muted-foreground"}`}>
<Icon className="size-3.5" />
<span className="bp-readout text-xs">{Math.round(level * 100)}%</span>
</span>
);
} }
export function DeviceCard({ export function DeviceCard({
device, device,
batteryLevel,
actuatorValues, actuatorValues,
transmittingFeatureIndex, transmittingFeatureIndex,
onActuatorChange, onActuatorChange,
onStop, onStop,
onDisconnect,
}: 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>
<Button variant="outline" size="sm" onClick={onStop}> <div className="flex shrink-0 items-center gap-3">
Stop {device.hasBattery &&
</Button> (batteryLevel !== null ? (
<BatteryIndicator level={batteryLevel} />
) : (
<Battery className="size-3.5 text-muted-foreground" aria-label="Reading battery level" />
))}
<Button variant="outline" size="sm" onClick={onStop}>
Stop
</Button>
<Button variant="ghost" size="icon-sm" onClick={onDisconnect} aria-label="Disconnect device">
<Unlink className="size-3.5" />
</Button>
</div>
</CardHeader> </CardHeader>
<div className="bp-hairline mx-4" /> <div className="bp-hairline mx-4" />
<CardContent className="flex flex-col gap-3 pt-3"> <CardContent className="flex flex-col gap-3 pt-3">
@@ -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>
); );
+59 -25
View File
@@ -1,11 +1,12 @@
"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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Check, Loader2 } from "lucide-react";
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 +15,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="relative w-48">
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 max-w-48" /> <Input
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving}> value={value}
Save onChange={(e) => setValue(e.target.value)}
</Button> className="h-8 pr-8"
aria-label="Device display name"
/>
<div className="pointer-events-none absolute inset-y-0 right-2 flex w-3.5 items-center justify-center">
<Loader2
className={cn(
"absolute size-3.5 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-3.5 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>
);
}
+20 -10
View File
@@ -1,18 +1,28 @@
"use client"; "use client";
import { useButtplugStore } from "@/lib/buttplug/store"; import { useButtplugStore } from "@/lib/buttplug/store";
import { cn } from "@/lib/utils";
type IndicatorState = "scanning" | "connected" | "recording";
export function ConnectionStatus() { export function ConnectionStatus() {
const connected = useButtplugStore((s) => s.connected); const scanning = useButtplugStore((s) => s.scanning);
const recording = useButtplugStore((s) => s.recording);
const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length); const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length);
return ( // Priority: an active recording is the most important thing to surface,
<div className="flex items-center gap-2 rounded-md border border-border bg-card/60 px-3 py-1 text-xs"> // then whether toys are actually connected, then a bare scan-in-progress -
<span className={cn("bp-led", connected && "bp-pulse")} data-on={connected} aria-hidden /> // this is deliberately independent of the Buttplug client's own `connected`
<span className="bp-readout text-muted-foreground"> // flag, which goes true as soon as the embedded server initializes (i.e. as
{connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "not connected"} // soon as scanning starts), not when a device is actually paired.
</span> const state: IndicatorState | null = recording
</div> ? "recording"
); : deviceCount > 0
? "connected"
: scanning
? "scanning"
: null;
if (!state) return null;
return <span className="bp-led bp-pulse" data-state={state} aria-hidden />;
} }
+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>
);
}
+10 -5
View File
@@ -1,5 +1,6 @@
"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";
@@ -13,12 +14,15 @@ import { cn } from "@/lib/utils";
const LINKS = [ const LINKS = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
{ href: "/control", label: "Control" }, { href: "/control", label: "Control" },
{ href: "/recordings", label: "Recordings" },
{ href: "/sessions", label: "Sessions" }, { href: "/sessions", label: "Sessions" },
{ href: "/stats", label: "Stats" }, { href: "/stats", label: "Stats" },
{ href: "/devices", label: "Devices" }, { href: "/devices", label: "Devices" },
]; ];
function isActive(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
}
function NavLinks({ onNavigate }: { onNavigate?: () => void }) { function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -30,7 +34,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
onClick={onNavigate} onClick={onNavigate}
className={cn( className={cn(
"border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors", "border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors",
pathname === link.href isActive(pathname, link.href)
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground", : "border-transparent text-muted-foreground hover:text-foreground",
)} )}
@@ -45,6 +49,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
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,7 +62,7 @@ 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 md:flex">
@@ -79,7 +84,7 @@ export function NavBar() {
<LogOut className="size-4" /> <LogOut className="size-4" />
</Button> </Button>
<Sheet> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu"> <Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
<Menu className="size-4" /> <Menu className="size-4" />
@@ -88,7 +93,7 @@ export function NavBar() {
<SheetContent side="right" className="w-64"> <SheetContent side="right" className="w-64">
<SheetTitle className="px-4 pt-4">Menu</SheetTitle> <SheetTitle className="px-4 pt-4">Menu</SheetTitle>
<nav className="flex flex-col gap-1 p-4"> <nav className="flex flex-col gap-1 p-4">
<NavLinks /> <NavLinks onNavigate={() => setMenuOpen(false)} />
</nav> </nav>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
-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>
);
}
-193
View File
@@ -1,193 +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 { getButtplugClientHandle, 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 connectedDevices = useMemo(() => Object.values(devices), [devices]);
const [data, setData] = useState<RecordingResponse | null>(null);
const [showRemap, setShowRemap] = useState(false);
const [player, setPlayer] = useState<RecordingPlayer | null>(null);
const [playing, setPlaying] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
fetch(`/api/recordings/${recordingId}`)
.then((r) => r.json())
.then(setData)
.catch(() => toast.error("Could not load recording"));
}, [recordingId]);
async function handleConfirmRemap(mapping: Map<number, number>) {
if (!data) return;
setShowRemap(false);
const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
const body = {
kind: "replay" as const,
replayedRecordingId: data.recording.id,
devices: orderedSlots.map((slot) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const device = connectedDevices.find((d) => d.index === deviceIndex)!;
return { slotLabel: slot.slotLabel, bleName: device.name };
}),
};
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]),
);
const { runtime } = await getButtplugClientHandle();
const instance = new RecordingPlayer({
events: data.events,
sessionDeviceIdToDeviceIndex,
actuatorsByDeviceIndex,
runtime,
onProgress: (elapsed) => setElapsedMs(elapsed),
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" }),
});
toast.success("Replay finished");
},
});
setPlayer(instance);
instance.play();
setPlaying(true);
}
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 () => {
await getButtplugClientHandle();
setShowRemap(true);
}}
disabled={connectedDevices.length === 0}
>
Match devices & replay
</Button>
</div>
</>
) : (
<div className="flex flex-col gap-3">
<Slider
value={[elapsedMs]}
max={data.recording.durationMs}
onValueChange={([v]) => player.seek(v)}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)}
</span>
<Button
size="icon"
variant="outline"
onClick={() => {
if (playing) {
player.pause();
setPlaying(false);
} else {
player.play();
setPlaying(true);
}
}}
>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
</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;
+259
View File
@@ -0,0 +1,259 @@
"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 { Pause, Play } from "lucide-react";
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 && (
<Card className="bp-glass">
<CardContent className="text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then match them to this session&apos;s
original device slots.
</CardContent>
</Card>
)}
</>
) : (
<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 outline-none transition-colors placeholder:text-muted-foreground/60 hover:border-b-border focus:border-b-foreground/40 focus:text-foreground sm:max-w-xl [field-sizing:content]"
/>
<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 outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-b-border focus:border-b-foreground/40 sm:max-w-xl [field-sizing:content]"
/>
<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>
);
}
+84 -41
View File
@@ -1,17 +1,26 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
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 { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
export interface SessionRow { export interface SessionRow {
id: number; id: number;
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;
@@ -27,14 +36,18 @@ 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 [pendingDelete, setPendingDelete] = useState<SessionRow | null>(null);
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;
setDeleting(true);
const res = await fetch(`/api/sessions/${pendingDelete.id}`, { method: "DELETE" });
setDeleting(false);
if (res.ok) { if (res.ok) {
toast.success("Session deleted"); toast.success("Session deleted");
setPendingDelete(null);
router.refresh(); router.refresh();
} else if (res.status === 409) {
toast.error("A saved recording still references this session");
} else { } else {
toast.error("Could not delete session"); toast.error("Could not delete session");
} }
@@ -45,41 +58,71 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
} }
return ( return (
<Table> <>
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>Session</TableHead> <TableRow>
<TableHead>Kind</TableHead> <TableHead>Session</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Started</TableHead> <TableHead>Started</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<Link href={`/sessions/${s.id}`} className="hover:underline">
{s.name ?? `Session #${s.id}`}
</Link>
</TableCell>
<TableCell>
<Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{s.status}</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{new Date(s.startedAt).toLocaleString()}
</TableCell>
<TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {sessions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
<Link href={`/sessions/${s.id}`} className="hover:underline">
{s.name ?? `Session #${s.id}`}
</Link>
</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANT[s.status]}>{s.status}</Badge>
</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{new Date(s.startedAt).toLocaleString()}
</TableCell>
<TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell>
<TableCell className="flex justify-end gap-1">
{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" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && setPendingDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete session?</DialogTitle>
<DialogDescription>
This permanently deletes{" "}
<span className="font-medium text-foreground">
{pendingDelete?.name ?? `Session #${pendingDelete?.id}`}
</span>{" "}
and all of its recorded events. This can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
+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;
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PageNavProps {
basePath: string;
page: number;
pageSize: number;
total: number;
}
export function PageNav({ basePath, page, pageSize, total }: PageNavProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
return (
<div className="flex items-center justify-between gap-4 pt-2">
<span className="bp-readout text-xs text-muted-foreground">
{from}-{to} of {total}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Button asChild variant="outline" size="icon-sm">
<Link href={`${basePath}?page=${page - 1}`} aria-label="Previous page">
<ChevronLeft className="size-3.5" />
</Link>
</Button>
) : (
<Button variant="outline" size="icon-sm" disabled aria-label="Previous page">
<ChevronLeft className="size-3.5" />
</Button>
)}
<span className="bp-readout text-xs text-muted-foreground">
{page} / {totalPages}
</span>
{page < totalPages ? (
<Button asChild variant="outline" size="icon-sm">
<Link href={`${basePath}?page=${page + 1}`} aria-label="Next page">
<ChevronRight className="size-3.5" />
</Link>
</Button>
) : (
<Button variant="outline" size="icon-sm" disabled aria-label="Next page">
<ChevronRight className="size-3.5" />
</Button>
)}
</div>
</div>
);
}
+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>
); );
} }
+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
} }
] ]
} }
+25
View File
@@ -1,3 +1,5 @@
import { createLogger } from "@/lib/logger";
export async function register() { export async function register() {
// Only the Node.js server runtime touches better-sqlite3; the Edge // Only the Node.js server runtime touches better-sqlite3; the Edge
// middleware runtime must never import this module. // middleware runtime must never import this module.
@@ -6,3 +8,26 @@ export async function register() {
runMigrations(); runMigrations();
} }
} }
export async function onRequestError(
error: unknown,
request: { path: string; method: string; headers: Record<string, string | string[]> },
context: { routerKind: string; routePath: string; routeType: string },
) {
const log = createLogger("uncaught");
// Safety net for errors that escape a route handler's own try/catch (e.g. a
// bug in code that never reaches withRouteLogging, or a rendering error) -
// route handlers wrapped in withRouteLogging already log and convert their
// own errors to a JSON 500, so this rarely double-logs the same failure.
const digest = typeof error === "object" && error !== null && "digest" in error ? String(error.digest) : undefined;
log.error("unhandled error", {
message: error instanceof Error ? error.message : String(error),
digest,
stack: error instanceof Error ? error.stack : undefined,
path: request.path,
method: request.method,
routePath: context.routePath,
routeType: context.routeType,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { createLogger } from "@/lib/logger";
const log = createLogger("api");
type RouteHandler<Ctx> = (req: Request, ctx: Ctx) => Promise<Response> | Response;
/**
* Wraps an App Router route handler with consistent request/response logging:
* a reqId (also echoed as `x-request-id`), method/path, status, and duration on
* every call, plus a logged stack trace and JSON 500 for anything the handler
* doesn't catch itself. Every export in app/api/** goes through this so log
* shape and error handling stay uniform across routes instead of being
* reimplemented per file.
*/
export function withRouteLogging<Ctx = unknown>(routeName: string, handler: RouteHandler<Ctx>): RouteHandler<Ctx> {
return async (req, ctx) => {
const reqId = randomUUID();
const start = performance.now();
const { pathname, search } = new URL(req.url);
const reqLog = log.child({ reqId, route: routeName, method: req.method });
reqLog.debug("request start", { path: pathname + search });
try {
const res = await handler(req, ctx);
const durationMs = Math.round(performance.now() - start);
const level = res.status >= 500 ? "error" : res.status >= 400 ? "warn" : "info";
reqLog[level]("request end", { status: res.status, durationMs });
res.headers.set("x-request-id", reqId);
return res;
} catch (err) {
const durationMs = Math.round(performance.now() - start);
reqLog.error("request failed", {
durationMs,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
return NextResponse.json({ error: "internal error", reqId }, { status: 500 });
}
};
}
+23 -2
View File
@@ -1,4 +1,4 @@
import type { ButtplugClient, ButtplugClientDevice } from "buttplug"; import type { ButtplugClient, ButtplugClientDevice, InputType } from "buttplug";
import { deriveActuators, type ButtplugRuntime } from "./commands"; import { deriveActuators, type ButtplugRuntime } from "./commands";
import { useButtplugStore } from "./store"; import { useButtplugStore } from "./store";
import type { ConnectedDeviceInfo } from "./types"; import type { ConnectedDeviceInfo } from "./types";
@@ -20,6 +20,9 @@ function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo {
name: device.name, name: device.name,
displayName: device.displayName, displayName: device.displayName,
actuators: deriveActuators(device), actuators: deriveActuators(device),
// "Battery" is a string enum value - passing the literal avoids needing
// InputType as a runtime import here (see commands.ts for the same trick).
hasBattery: device.hasInput("Battery" as InputType),
}; };
} }
@@ -45,7 +48,14 @@ async function initClient(): Promise<ClientHandle> {
}); });
const connector = new ButtplugWasmClientConnector(); const connector = new ButtplugWasmClientConnector();
await client.connect(connector); // EXPERIMENTAL: buttplug-wasm@3.0.0's embedded server was built against
// buttplug@^4.0.2's wire format; buttplug@5's OutputCmd message shape
// changed (`Value` went from number[] to a bare number), which is a real
// protocol break, not just a type mismatch - the cast below silences that
// signal rather than fixing it. Scanning/pairing may still work since
// DeviceAdded's shape didn't change; device *commands* may be silently
// rejected or mis-parsed server-side. Revert to buttplug@^4.0.2 if so.
await client.connect(connector as unknown as Parameters<typeof client.connect>[0]);
useButtplugStore.getState().setConnected(true); useButtplugStore.getState().setConnected(true);
for (const device of client.devices.values()) { for (const device of client.devices.values()) {
@@ -94,3 +104,14 @@ export async function getDevice(deviceIndex: number): Promise<ButtplugClientDevi
const { client } = await getButtplugClientHandle(); const { client } = await getButtplugClientHandle();
return client.devices.get(deviceIndex); return client.devices.get(deviceIndex);
} }
/** Reads the current battery level (0-1), or null if unsupported/unreadable. */
export async function getBatteryLevel(deviceIndex: number): Promise<number | null> {
const device = await getDevice(deviceIndex);
if (!device || !device.hasInput("Battery" as InputType)) return null;
try {
return await device.battery();
} catch {
return null;
}
}
+4 -2
View File
@@ -2,8 +2,10 @@ import type { ButtplugClientDevice, DeviceOutputCommand, OutputType } from "butt
import type { ActuatorInfo, NormalizedOutputType } from "./types"; import type { ActuatorInfo, NormalizedOutputType } from "./types";
// buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself, // buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself,
// so its type is recovered from the `features` map it's stored in. // so its type is recovered from the `features` map it's stored in. Matched
type DeviceFeature = ButtplugClientDevice["features"] extends Map<number, infer F> ? F : never; // against ReadonlyMap (not Map) so this works whether `.features` returns a
// mutable Map (v4) or a ReadonlyMap (v5) - Map structurally extends ReadonlyMap.
type DeviceFeature = ButtplugClientDevice["features"] extends ReadonlyMap<number, infer F> ? F : never;
/** /**
* The pieces of the dynamically-imported `buttplug` module namespace this * The pieces of the dynamically-imported `buttplug` module namespace this
+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" }),
); );
}; };
+30 -29
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,22 +12,29 @@ export interface RecordingEventRow {
} }
export interface PlayerOptions { export interface PlayerOptions {
events: RecordingEventRow[]; events: SessionEventRow[];
/** Recording's session_device_id -> currently-connected device index, from the remap step. */ /** The source session's actual duration (sessions.duration_ms from the DB) - this is
* the source of truth for playback length, NOT the last event's timestamp: a session can run
* for a while after its last command (e.g. the user stopped the toy but let the session
* continue), so deriving duration from events would end playback early and misreport it as
* "finished". */
durationMs: number;
/** 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;
speed?: number; speed?: number;
onProgress?: (elapsedMs: number, durationMs: number) => void; onProgress?: (elapsedMs: number, durationMs: number) => void;
onComplete?: () => void; onComplete?: () => void;
onError?: (message: string) => void;
} }
/** /**
* 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;
@@ -37,7 +43,7 @@ export class RecordingPlayer {
private playing = false; private playing = false;
constructor(private readonly options: PlayerOptions) { constructor(private readonly options: PlayerOptions) {
this.durationMs = options.events.at(-1)?.tsMs ?? 0; this.durationMs = options.durationMs;
} }
get isPlaying(): boolean { get isPlaying(): boolean {
@@ -104,33 +110,28 @@ 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;
const device = await getDevice(deviceIndex); try {
if (!device) return; const device = await getDevice(deviceIndex);
if (!device) return;
eventBuffer.record({ if (event.commandType === "stop") {
deviceIndex, await device.stop();
commandType: event.commandType, } else {
featureIndex: event.featureIndex, const actuator = this.options.actuatorsByDeviceIndex
value: event.value, .get(deviceIndex)
durationMs: event.durationMs ?? undefined, ?.find((a) => a.featureIndex === event.featureIndex);
}); const feature = findFeature(device, event.featureIndex);
if (!actuator || !feature) return;
if (event.commandType === "stop") { const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
await device.stop(); await feature.runOutput(cmd);
return; }
} catch (err) {
this.options.onError?.(err instanceof Error ? err.message : String(err));
} }
const actuator = this.options.actuatorsByDeviceIndex
.get(deviceIndex)
?.find((a) => a.featureIndex === event.featureIndex);
const feature = findFeature(device, event.featureIndex);
if (!actuator || !feature) return;
const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
await feature.runOutput(cmd);
} }
} }
+16 -2
View File
@@ -7,15 +7,21 @@ export const actuatorKey = (deviceIndex: number, featureIndex: number): string =
interface ButtplugStoreState { interface ButtplugStoreState {
connected: boolean; connected: boolean;
scanning: boolean; scanning: boolean;
/** Whether a play session is actively being recorded (see ButtplugConsole's session lifecycle). */
recording: boolean;
devices: Record<number, ConnectedDeviceInfo>; devices: Record<number, ConnectedDeviceInfo>;
/** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */ /** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */
actuatorValues: Record<string, number>; actuatorValues: Record<string, number>;
/** Last-known battery reading (0-1) per device index; absent until first read completes. */
batteryLevels: Record<number, number | null>;
error: string | null; error: string | null;
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
setScanning: (scanning: boolean) => void; setScanning: (scanning: boolean) => void;
setRecording: (recording: boolean) => void;
upsertDevice: (device: ConnectedDeviceInfo) => void; upsertDevice: (device: ConnectedDeviceInfo) => void;
removeDevice: (index: number) => void; removeDevice: (index: number) => void;
setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void; setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void;
setBatteryLevel: (deviceIndex: number, level: number | null) => void;
setError: (message: string | null) => void; setError: (message: string | null) => void;
reset: () => void; reset: () => void;
} }
@@ -23,22 +29,30 @@ interface ButtplugStoreState {
export const useButtplugStore = create<ButtplugStoreState>((set) => ({ export const useButtplugStore = create<ButtplugStoreState>((set) => ({
connected: false, connected: false,
scanning: false, scanning: false,
recording: false,
devices: {}, devices: {},
actuatorValues: {}, actuatorValues: {},
batteryLevels: {},
error: null, error: null,
setConnected: (connected) => set({ connected }), setConnected: (connected) => set({ connected }),
setScanning: (scanning) => set({ scanning }), setScanning: (scanning) => set({ scanning }),
setRecording: (recording) => set({ recording }),
upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })), upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })),
removeDevice: (index) => removeDevice: (index) =>
set((s) => { set((s) => {
const devices = { ...s.devices }; const devices = { ...s.devices };
delete devices[index]; delete devices[index];
return { devices }; const batteryLevels = { ...s.batteryLevels };
delete batteryLevels[index];
return { devices, batteryLevels };
}), }),
setActuatorValue: (deviceIndex, featureIndex, value) => setActuatorValue: (deviceIndex, featureIndex, value) =>
set((s) => ({ set((s) => ({
actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value }, actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value },
})), })),
setBatteryLevel: (deviceIndex, level) =>
set((s) => ({ batteryLevels: { ...s.batteryLevels, [deviceIndex]: level } })),
setError: (message) => set({ error: message }), setError: (message) => set({ error: message }),
reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {} }), reset: () =>
set({ connected: false, scanning: false, recording: false, devices: {}, actuatorValues: {}, batteryLevels: {} }),
})); }));
+18
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";
@@ -20,6 +22,7 @@ export interface ConnectedDeviceInfo {
name: string; name: string;
displayName?: string; displayName?: string;
actuators: ActuatorInfo[]; actuators: ActuatorInfo[];
hasBattery: boolean;
} }
/** A single dispatched command, timestamped relative to session start. */ /** A single dispatched command, timestamped relative to session start. */
@@ -31,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;
}
+13 -1
View File
@@ -1,11 +1,23 @@
import { eq } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { devices, type DeviceCapabilities } from "@/lib/db/schema"; import { devices, type DeviceCapabilities } from "@/lib/db/schema";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
export async function listDevices() { export async function listDevices() {
return db.select().from(devices).orderBy(devices.lastConnectedAt); return db.select().from(devices).orderBy(devices.lastConnectedAt);
} }
export async function listDevicesPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof devices.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(devices);
const items = await db
.select()
.from(devices)
.orderBy(desc(devices.lastConnectedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
export async function getDevice(id: number) { export async function getDevice(id: number) {
const [row] = await db.select().from(devices).where(eq(devices.id, id)); const [row] = await db.select().from(devices).where(eq(devices.id, id));
return row; return row;
-112
View File
@@ -1,112 +0,0 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, devices } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { incrementPlayCount } from "./recordings";
import type { DeviceCapabilities } from "@/lib/db/schema";
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 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) {
await db.delete(playSessions).where(eq(playSessions.id, id));
}
-86
View File
@@ -1,86 +0,0 @@
import { desc, eq } 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";
/**
* 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 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));
}
+21 -56
View File
@@ -1,45 +1,33 @@
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)
// in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that
// SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier
// against, so referencing it by name causes "no such column: bucket".
const bucket = sql<number>`(${sessionEvents.tsMs} / ${bucketMs}) * ${bucketMs}`;
return db return db
.select({ .select({
bucket: sql<number>`(${sessionEvents.tsMs} / ${bucketMs}) * ${bucketMs}`, bucket,
sessionDeviceId: sessionEvents.sessionDeviceId, sessionDeviceId: sessionEvents.sessionDeviceId,
slotLabel: sessionDevices.slotLabel, slotLabel: sessionDevices.slotLabel,
avgValue: sql<number>`avg(${sessionEvents.value})`, avgValue: sql<number>`avg(${sessionEvents.value})`,
@@ -47,9 +35,9 @@ 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(sql`bucket`, sessionEvents.sessionDeviceId) .groupBy(bucket, sessionEvents.sessionDeviceId)
.orderBy(sql`bucket`); .orderBy(bucket);
} }
export async function getDeviceUsageStats() { export async function getDeviceUsageStats() {
@@ -58,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})`,
}) })
@@ -78,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"),
});
+12 -5
View File
@@ -34,14 +34,21 @@ export interface Logger {
info(message: string, fields?: LogFields): void; info(message: string, fields?: LogFields): void;
warn(message: string, fields?: LogFields): void; warn(message: string, fields?: LogFields): void;
error(message: string, fields?: LogFields): void; error(message: string, fields?: LogFields): void;
/** Returns a logger that merges `bindings` into every call's fields - for attaching
* per-request context (reqId, route) without threading it through every log call. */
child(bindings: LogFields): Logger;
} }
/** Scoped logger. `scope` is the bracketed tag, e.g. createLogger("api") -> "[api]". */ /** Scoped logger. `scope` is the bracketed tag, e.g. createLogger("api") -> "[api]". */
export function createLogger(scope: string): Logger { export function createLogger(scope: string, bindings?: LogFields): Logger {
const log = (level: LogLevel, message: string, fields?: LogFields) =>
write(level, scope, message, bindings || fields ? { ...bindings, ...fields } : undefined);
return { return {
debug: (message, fields) => write("debug", scope, message, fields), debug: (message, fields) => log("debug", message, fields),
info: (message, fields) => write("info", scope, message, fields), info: (message, fields) => log("info", message, fields),
warn: (message, fields) => write("warn", scope, message, fields), warn: (message, fields) => log("warn", message, fields),
error: (message, fields) => write("error", scope, message, fields), error: (message, fields) => log("error", message, fields),
child: (extra) => createLogger(scope, { ...bindings, ...extra }),
}; };
} }
+13
View File
@@ -0,0 +1,13 @@
export const PAGE_SIZE = 20;
export function parsePage(value: string | string[] | undefined): number {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : 1;
}
export interface Page<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.1.1", "version": "0.8.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"buttplug": "^4.0.2", "buttplug": "^5.0.1",
"buttplug-wasm": "^3.0.0", "buttplug-wasm": "^3.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+13 -2
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^13.0.3 specifier: ^13.0.3
version: 13.0.3 version: 13.0.3
buttplug: buttplug:
specifier: ^4.0.2 specifier: ^5.0.1
version: 4.0.2 version: 5.0.1
buttplug-wasm: buttplug-wasm:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -2292,6 +2292,9 @@ packages:
buttplug@4.0.2: buttplug@4.0.2:
resolution: {integrity: sha512-PciEJEoBkHjeA0UFfdymr5+jHXukzF0T8Pg7TaAxxWFb/0Ynfy1dWaGt1c7O5E1sfJSVyWxrvBQQCSeNWzprbg==} resolution: {integrity: sha512-PciEJEoBkHjeA0UFfdymr5+jHXukzF0T8Pg7TaAxxWFb/0Ynfy1dWaGt1c7O5E1sfJSVyWxrvBQQCSeNWzprbg==}
buttplug@5.0.1:
resolution: {integrity: sha512-m7Qzoi6TEsr0DkqqXc3Gun/wikWiHziQeXu73Oxzd1ETqj2y1P0VdJ9QxAT811bLcTIYvyf0VkuHlXbEGcEXCw==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -6531,6 +6534,14 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
buttplug@5.0.1:
dependencies:
eventemitter3: 5.0.4
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
+5
View File
@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth/session"; import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth/session";
import { createLogger } from "@/lib/logger";
const log = createLogger("auth");
export default async function proxy(req: NextRequest) { export default async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
@@ -13,9 +16,11 @@ export default async function proxy(req: NextRequest) {
} }
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
log.warn("rejected unauthenticated request", { path: pathname, method: req.method });
return NextResponse.json({ error: "unauthorized" }, { status: 401 }); return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} }
log.warn("redirecting unauthenticated request to login", { path: pathname });
const loginUrl = new URL("/login", req.url); const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("from", pathname); loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);