commit 1119c8eea0b4485831325d2b116084568acbced8 Author: Sebastian Krüger Date: Tue Aug 25 07:51:38 2026 +0200 Initial implementation of Bluetooth toy control app Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c0ab2a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +data +*.log +.DS_Store +.env* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..114cec1 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# The shared secret users type into the login form. Compared with a +# constant-time check - not stored as a hash since it's a single app-wide +# gate, not a per-user credential store. +ACCESS_PASSWORD=change-me + +# Signing key for the session JWT. Deliberately separate from +# ACCESS_PASSWORD - reusing the login password as the signing key would let a +# JWT-signing weakness leak the login secret itself. Generate one with: +# openssl rand -base64 32 +AUTH_SECRET=change-me-too-at-least-16-chars + +# Path to the SQLite database file. In Docker this should point inside the +# ./data bind mount so it survives container restarts. +DATABASE_PATH=./data/app.db + +# debug | info | warn | error (default: debug in development, info in production) +LOG_LEVEL=info + +# --- Compose-level only --- +# Docker Compose reads this same file for ${...} substitution in +# docker-compose.yml (Traefik labels, network name) - these aren't read by +# the app itself. +TRAEFIK_HOST=sexy.example.com +NETWORK_NAME=compose_network diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ae0a143 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + pull_request: + +jobs: + checks: + name: Static checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + # `next build` runs its own full TypeScript check as part of the + # build - a separate `tsc --noEmit` here would fail on a clean + # checkout anyway, since it needs .next/types (generated by this + # same build) for Next's own ambient types like LayoutProps. + - name: Build + run: pnpm build + + publish: + name: Build and push image + if: startsWith(github.ref, 'refs/tags/') + needs: checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to the Gitea container registry + run: echo "$PACKAGE_TOKEN" | docker login dev.pivoine.art -u ${{ github.repository_owner }} --password-stdin + env: + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + + - name: Build image + run: | + docker build \ + -t dev.pivoine.art/${{ github.repository }}:latest \ + -t dev.pivoine.art/${{ github.repository }}:${{ github.ref_name }} \ + . + + - name: Push image + run: | + docker push dev.pivoine.art/${{ github.repository }}:latest + docker push dev.pivoine.art/${{ github.repository }}:${{ github.ref_name }} + + - name: Log out + if: always() + run: docker logout dev.pivoine.art diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..503224e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules +.next +out +build +*.tsbuildinfo +next-env.d.ts + +# env +.env +.env.local + +# sqlite data +data/*.db +data/*.db-journal +data/*.db-wal +data/*.db-shm + +# misc +.DS_Store +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f07d93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# `output: 'standalone'` in next.config.ts (no custom server here, unlike +# pulsenode's, so standalone is viable) traces only what's actually required +# and copies better-sqlite3's compiled native binary along with it - verified +# by inspecting .next/standalone/node_modules after a local build. + +FROM node:22-alpine AS base +RUN corepack enable + +FROM base AS deps +WORKDIR /app +# better-sqlite3 ships prebuilt binaries for linux-musl (this image's libc), +# but the toolchain is installed defensively in case no prebuild matches this +# Node version - prebuild-install falls back to compiling from source then. +RUN apk add --no-cache python3 make g++ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM base AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM node:22-alpine AS runtime +RUN apk add --no-cache tini +WORKDIR /app +ENV NODE_ENV=production +# Docker auto-sets HOSTNAME to the container id; the standalone server.js +# binds to `process.env.HOSTNAME || '0.0.0.0'`, so left unset it would bind +# only to that container-id hostname's address instead of all interfaces, +# making the app unreachable via the published port. +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 10001 -S app && adduser -u 10001 -S app -G app + +# Standalone output ships its own minimal node_modules + server.js; static +# assets, public/, and the drizzle migrations aren't traced (they're read +# from disk at runtime, not imported), so they're copied in separately. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +COPY --from=build /app/drizzle ./drizzle +COPY --from=build /app/healthcheck.mjs ./healthcheck.mjs + +RUN mkdir -p /app/data && chown -R app:app /app + +USER app +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["node", "healthcheck.mjs"] + +ENTRYPOINT ["tini", "--"] +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d332a90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Krüger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbde0c5 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# sexy + +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 +other legitimate Bluetooth toy-control apps. Record a live control session, save it as a named recording, +and replay it later against a (possibly different) set of connected devices. Track usage in a stats +dashboard. All gated behind a single shared secret, all data kept in a local SQLite file. + +## Architecture, in words + +Web Bluetooth (`navigator.bluetooth`) only exists inside a browser, in a secure context (HTTPS or +`localhost`), and requires a user gesture to start a device scan. A Docker container has no access to host +Bluetooth hardware at all. So: + +- **The actual Buttplug client and device connector run entirely in your browser**, using `buttplug-wasm` + 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. +- **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 + recordings back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick. + +``` + Browser Server (Next.js) + ┌─────────────────────────┐ ┌───────────────────────┐ + │ buttplug-wasm (embedded)│ │ auth / API routes │ + │ ↕ Web Bluetooth │ HTTPS │ recordings + sessions │ + │ BLE devices │ ───────► │ stats aggregation │ + │ live control / replay │ (batch) │ SQLite (better-sqlite3)│ + └─────────────────────────┘ └───────────────────────┘ +``` + +## Browser support (read this first) + +- **Works:** Chrome or Edge on desktop, Chrome on Android, over HTTPS (or `localhost` in development). +- **Does not work, at all, on any browser: iOS and macOS Safari.** Every browser on iOS is WebKit under + the hood by Apple's policy, and WebKit has no Web Bluetooth implementation. This is a platform limitation, + not a bug in this app - there's no workaround short of Apple shipping Web Bluetooth support. +- The app itself detects `'bluetooth' in navigator` and shows a clear message instead of a broken UI on + unsupported browsers. + +## Features + +- Scan for and connect to multiple Bluetooth LE devices at once +- Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI +- Start/end sessions; save a completed session as a named, replayable recording +- Replay a recording against a different set of connected devices, with a device-remap step (BLE exposes no + stable device id across sessions, so recorded devices are matched to live ones by name, with manual + override when needed) +- Stats dashboard: session summaries, per-session intensity timelines, per-device usage, recording-library + stats (play counts, average length) +- Single shared-secret login, no user accounts +- SQLite storage, Docker Compose deployment, Gitea Actions CI + +## Tech stack + +Next.js (App Router) · Tailwind CSS v4 · shadcn/ui · `buttplug` + `buttplug-wasm` · Drizzle ORM + +better-sqlite3 · zustand · jose · pnpm + +## Local development + +Requires Node 22+, pnpm, and a Chromium-based browser (Chrome or Edge) for actually exercising the Bluetooth +parts - `http://localhost:3000` counts as a secure context, so no HTTPS setup is needed in dev. + +```bash +pnpm install +cp .env.example .env # fill in ACCESS_PASSWORD and AUTH_SECRET +pnpm db:generate # only needed after changing lib/db/schema.ts +pnpm dev +``` + +Migrations run automatically against `DATABASE_PATH` on server boot (see `instrumentation.ts`) - no manual +migrate step needed for a normal `pnpm dev` / `pnpm start`. `pnpm db:migrate` exists for applying migrations +out-of-band if you ever need to. + +## Environment variables + +| Variable | Required | Description | +| ------------------ | -------- | ----------------------------------------------------------------------------- | +| `ACCESS_PASSWORD` | yes | The shared secret typed into the login form. Compared in constant time. | +| `AUTH_SECRET` | yes | Signing key for the session JWT. Must differ from `ACCESS_PASSWORD` - keep both secret; a leaked signing key lets an attacker forge sessions. | +| `DATABASE_PATH` | no | Path to the SQLite file. Default `./data/app.db`. | +| `LOG_LEVEL` | no | `debug` \| `info` \| `warn` \| `error`. Defaults to `debug` in dev, `info` in production. | + +Compose-only (read by `docker-compose.yml` for `${...}` substitution, not by the app itself): +`TRAEFIK_HOST`, `NETWORK_NAME`. + +## Database + +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 +session's events, not a separate capture pipeline - every live command is always persisted for stats, +regardless of whether the session is later saved as a named recording. + +## Docker deployment + +```bash +mkdir -p data && chown 10001:10001 data # match the container's non-root uid/gid +cp .env.example .env # fill in real secrets + TRAEFIK_HOST/NETWORK_NAME +docker compose up -d --build +``` + +**HTTPS in front of this container is not optional.** Web Bluetooth refuses to run outside a secure +context, so if the app isn't served over HTTPS (via Traefik, as the included labels assume, or another +reverse proxy terminating TLS), the Control and Replay pages simply won't be able to use Bluetooth at all. + +The SQLite file lives at `./data/app.db` on the host (bind-mounted, read-write) - everything else in the +container filesystem is read-only. Back up by copying that one file; there's no other state. + +## CI/CD + +`.gitea/workflows/ci.yml` runs lint + build on every push/PR, and on a pushed tag also builds and pushes the +Docker image to this repo's path on the `dev.pivoine.art` Gitea registry, authenticating with a +`PACKAGE_TOKEN` repo secret. + +## Usage guide + +1. Log in with the shared secret. +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 + recording; **End session** when done, and optionally name it to save it as a recording. +4. On **Recordings**, click replay on a saved recording, connect the devices you want to use, match them to + the recording's original device slots, and play back. +5. **Sessions** and **Stats** show history and aggregated usage. + +## Security & privacy notes + +- 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 + 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 + anywhere except directly between your browser and the devices it connects to, and between your browser + and this server. +- There's no login rate-limiting in this version - acceptable behind a private deployment, but worth + knowing. + +## Known limitations + +- **iOS/Safari cannot use Web Bluetooth at all** (see above) - not fixable from this app. +- BLE device identity isn't stable across browser sessions; the device-remap step mitigates but can't fully + solve matching two identically-named devices. +- Concurrent BLE connection limits are set by the OS/Bluetooth adapter, not this app. +- Replay timing uses `performance.now()`-relative scheduling to avoid wall-clock drift, but individual + command dispatch still has a few-to-tens-of-milliseconds of jitter. + +## License + +MIT, see [LICENSE](./LICENSE). diff --git a/app/(app)/control/page.tsx b/app/(app)/control/page.tsx new file mode 100644 index 0000000..5bb4f7a --- /dev/null +++ b/app/(app)/control/page.tsx @@ -0,0 +1,16 @@ +import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader"; + +export default function ControlPage() { + return ( +
+
+

Control

+

+ Scan for devices, connect, and control them live. Every command runs directly from your browser + to the device over Web Bluetooth - the server never sees it in real time. +

+
+ +
+ ); +} diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx new file mode 100644 index 0000000..94cc530 --- /dev/null +++ b/app/(app)/devices/page.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DevicesTable } from "@/components/devices/DevicesTable"; +import { listDevices } from "@/lib/db/queries/devices"; + +export const dynamic = "force-dynamic"; + +export default async function DevicesPage() { + const devices = await listDevices(); + + return ( +
+
+

Devices

+

Devices seen across past sessions. Give them friendlier names.

+
+ + + Known devices + + + + + +
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..0c53835 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,10 @@ +import { NavBar } from "@/components/layout/NavBar"; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx new file mode 100644 index 0000000..ef45e04 --- /dev/null +++ b/app/(app)/page.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { listRecordings } from "@/lib/db/queries/recordings"; +import { listPlaySessions } from "@/lib/db/queries/play-sessions"; +import { getSessionsSummary } from "@/lib/db/queries/stats"; + +// Always reflects live DB state for an authenticated, single-tenant app - +// never worth prerendering (and the DB file doesn't exist at build time). +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const [recordings, sessions, summary] = await Promise.all([ + listRecordings(), + listPlaySessions(), + getSessionsSummary(), + ]); + + const recentRecordings = recordings.slice(0, 5); + const recentSessions = [...sessions].reverse().slice(0, 5); + + return ( +
+ + +

Welcome back

+

+ Scan for nearby devices, take control, and record sessions to replay later - all running + directly from your browser over Web Bluetooth. +

+ +
+
+ +
+ + + Completed sessions + + {summary.count} + + + + Total play time + + + {(summary.totalDurationMs / 3_600_000).toFixed(1)}h + + + + + Saved recordings + + {recordings.length} + +
+ +
+ + + Recent recordings + + + {recentRecordings.length === 0 && ( +

Nothing saved yet.

+ )} + {recentRecordings.map((r) => ( + + {r.name} + {r.playCount} plays + + ))} +
+
+ + + + Recent sessions + + + {recentSessions.length === 0 &&

No sessions yet.

} + {recentSessions.map((s) => ( + + {s.name ?? `Session #${s.id}`} + {new Date(s.startedAt).toLocaleDateString()} + + ))} +
+
+
+
+ ); +} diff --git a/app/(app)/recordings/[id]/page.tsx b/app/(app)/recordings/[id]/page.tsx new file mode 100644 index 0000000..fdbc1ee --- /dev/null +++ b/app/(app)/recordings/[id]/page.tsx @@ -0,0 +1,74 @@ +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 ( +
+
+
+

{recording.name}

+ {recording.description &&

{recording.description}

} +
+ +
+ + + + Details + + +
+ Duration: + {formatDuration(recording.durationMs)} +
+
+ Plays: + {recording.playCount} +
+
+ Created: + {new Date(recording.createdAt).toLocaleString()} +
+
+ Last played: + {recording.lastPlayedAt ? new Date(recording.lastPlayedAt).toLocaleString() : "Never"} +
+
+
+ + + + Devices in this recording + + + {recording.deviceSlots.map((slot) => ( +
+ {slot.slotLabel} ({slot.recordedBleName}) +
+ ))} +
+
+
+ ); +} diff --git a/app/(app)/recordings/[id]/replay/page.tsx b/app/(app)/recordings/[id]/replay/page.tsx new file mode 100644 index 0000000..a9c0835 --- /dev/null +++ b/app/(app)/recordings/[id]/replay/page.tsx @@ -0,0 +1,11 @@ +import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader"; + +export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( +
+

Replay

+ +
+ ); +} diff --git a/app/(app)/recordings/page.tsx b/app/(app)/recordings/page.tsx new file mode 100644 index 0000000..ce80392 --- /dev/null +++ b/app/(app)/recordings/page.tsx @@ -0,0 +1,26 @@ +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 ( +
+
+

Recordings

+

Saved sessions you can replay against connected devices.

+
+ + + Library + + + + + +
+ ); +} diff --git a/app/(app)/sessions/[id]/page.tsx b/app/(app)/sessions/[id]/page.tsx new file mode 100644 index 0000000..56c8438 --- /dev/null +++ b/app/(app)/sessions/[id]/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; +import { getPlaySessionDetail } from "@/lib/db/queries/play-sessions"; +import { getSessionTimeline } from "@/lib/db/queries/stats"; + +export const dynamic = "force-dynamic"; + +function formatDuration(ms: number | null): string { + if (ms === null) return "-"; + const totalSeconds = Math.round(ms / 1000); + return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`; +} + +export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const sessionId = Number(id); + const [detail, timeline] = await Promise.all([getPlaySessionDetail(sessionId), getSessionTimeline(sessionId)]); + if (!detail) notFound(); + + return ( +
+
+

{detail.session.name ?? `Session #${detail.session.id}`}

+

+ {detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)} +

+
+ + + + Intensity timeline + + + + + + + + + Devices + + + {detail.devices.map((d) => ( +
+ {d.slotLabel} ({d.deviceDisplayName ?? d.deviceBleName}) +
+ ))} +
+
+
+ ); +} diff --git a/app/(app)/sessions/page.tsx b/app/(app)/sessions/page.tsx new file mode 100644 index 0000000..c9e7b4c --- /dev/null +++ b/app/(app)/sessions/page.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { SessionsTable } from "@/components/sessions/SessionsTable"; +import { listPlaySessions } from "@/lib/db/queries/play-sessions"; + +export const dynamic = "force-dynamic"; + +export default async function SessionsPage() { + const sessions = [...(await listPlaySessions())].reverse(); + + return ( +
+
+

Sessions

+

History of live control and replay sessions.

+
+ + + History + + + + + +
+ ); +} diff --git a/app/(app)/stats/page.tsx b/app/(app)/stats/page.tsx new file mode 100644 index 0000000..7f7fb71 --- /dev/null +++ b/app/(app)/stats/page.tsx @@ -0,0 +1,45 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; +import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; +import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats"; +import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats"; + +export const dynamic = "force-dynamic"; + +export default async function StatsPage() { + const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([ + getSessionsSummary(), + getDeviceUsageStats(), + getDeviceCommandCounts(), + getRecordingLibraryStats(), + ]); + + const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); + const devices = deviceUsage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 })); + + return ( +
+
+

Stats

+

Usage across sessions, devices, and your recording library.

+
+ + + + Sessions + Devices + Recordings + + + + + + + + + + + +
+ ); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..2dc1424 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getEnv } from "@/lib/env"; +import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare"; +import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session"; + +const bodySchema = z.object({ secret: z.string().min(1) }); + +export async function POST(req: Request) { + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "secret is required" }, { status: 400 }); + } + + if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) { + return NextResponse.json({ error: "invalid secret" }, { status: 401 }); + } + + const token = await createSessionToken(); + const res = NextResponse.json({ ok: true }); + res.cookies.set({ ...sessionCookieOptions, value: token }); + return res; +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a3277ea --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.delete(SESSION_COOKIE_NAME); + return res; +} diff --git a/app/api/devices/[id]/route.ts b/app/api/devices/[id]/route.ts new file mode 100644 index 0000000..ef6898e --- /dev/null +++ b/app/api/devices/[id]/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { renameDevice } from "@/lib/db/queries/devices"; + +const bodySchema = z.object({ displayName: z.string().min(1).max(120) }); + +export async function PATCH(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: "displayName is required" }, { status: 400 }); + } + const updated = await renameDevice(Number(id), parsed.data.displayName); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ device: updated }); +} diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts new file mode 100644 index 0000000..9ee1bad --- /dev/null +++ b/app/api/devices/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { listDevices } from "@/lib/db/queries/devices"; + +export async function GET() { + const rows = await listDevices(); + return NextResponse.json({ devices: rows }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..7afa84e --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { sqlite } from "@/lib/db/client"; + +export async function GET() { + try { + sqlite.prepare("select 1").get(); + return NextResponse.json({ status: "ok" }); + } catch { + return NextResponse.json({ status: "error" }, { status: 500 }); + } +} diff --git a/app/api/play-sessions/[id]/events/route.ts b/app/api/play-sessions/[id]/events/route.ts new file mode 100644 index 0000000..b14dcd5 --- /dev/null +++ b/app/api/play-sessions/[id]/events/route.ts @@ -0,0 +1,24 @@ +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 }); +} diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts new file mode 100644 index 0000000..150bc9f --- /dev/null +++ b/app/api/play-sessions/[id]/route.ts @@ -0,0 +1,40 @@ +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; + } +} diff --git a/app/api/play-sessions/route.ts b/app/api/play-sessions/route.ts new file mode 100644 index 0000000..5255431 --- /dev/null +++ b/app/api/play-sessions/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions"; + +const deviceInputSchema = z.object({ + slotLabel: z.string().min(1), + bleName: z.string().min(1), + deviceClass: z.string().nullish(), + capabilities: z.object({ outputs: z.array(z.string()), featureCount: z.number() }).optional(), +}); + +const bodySchema = z.object({ + kind: z.enum(["live", "replay"]), + replayedRecordingId: z.number().int().positive().optional(), + name: z.string().min(1).optional(), + devices: z.array(deviceInputSchema).min(1), +}); + +export async function GET() { + const rows = await listPlaySessions(); + return NextResponse.json({ playSessions: 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 result = await startPlaySession(parsed.data); + return NextResponse.json(result, { status: 201 }); +} diff --git a/app/api/recordings/[id]/route.ts b/app/api/recordings/[id]/route.ts new file mode 100644 index 0000000..d544f0c --- /dev/null +++ b/app/api/recordings/[id]/route.ts @@ -0,0 +1,32 @@ +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 }); +} diff --git a/app/api/recordings/route.ts b/app/api/recordings/route.ts new file mode 100644 index 0000000..64b4c1c --- /dev/null +++ b/app/api/recordings/route.ts @@ -0,0 +1,23 @@ +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 }); +} diff --git a/app/api/stats/devices/route.ts b/app/api/stats/devices/route.ts new file mode 100644 index 0000000..b87697b --- /dev/null +++ b/app/api/stats/devices/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats"; + +export async function GET() { + const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]); + const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); + const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 })); + return NextResponse.json({ devices }); +} diff --git a/app/api/stats/recordings/route.ts b/app/api/stats/recordings/route.ts new file mode 100644 index 0000000..caa6d43 --- /dev/null +++ b/app/api/stats/recordings/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { getRecordingLibraryStats } from "@/lib/db/queries/stats"; + +export async function GET() { + const stats = await getRecordingLibraryStats(); + return NextResponse.json(stats); +} diff --git a/app/api/stats/sessions/[id]/timeline/route.ts b/app/api/stats/sessions/[id]/timeline/route.ts new file mode 100644 index 0000000..97d45ed --- /dev/null +++ b/app/api/stats/sessions/[id]/timeline/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { getSessionTimeline } from "@/lib/db/queries/stats"; + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); + const timeline = await getSessionTimeline(Number(id), bucketMs); + return NextResponse.json({ timeline }); +} diff --git a/app/api/stats/sessions/route.ts b/app/api/stats/sessions/route.ts new file mode 100644 index 0000000..9d6321c --- /dev/null +++ b/app/api/stats/sessions/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { getSessionsSummary } from "@/lib/db/queries/stats"; + +export async function GET() { + const summary = await getSessionsSummary(); + return NextResponse.json(summary); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..fa1de1c --- /dev/null +++ b/app/globals.css @@ -0,0 +1,194 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-display); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +/* + * Palette: dark-mode-first (fits the product's low-lit, intimate register). + * Every hue anchors to the pink -> purple -> blue brand sweep; --primary/ + * --accent/--chart-* are the only places it should read as loud. Card + * surfaces stay a near-neutral indigo-black so the sweep doesn't flood + * every panel. + */ +:root { + --background: oklch(0.99 0.005 300); + --foreground: oklch(0.22 0.03 290); + --card: oklch(0.98 0.008 300); + --card-foreground: oklch(0.22 0.03 290); + --popover: oklch(0.99 0.005 300); + --popover-foreground: oklch(0.22 0.03 290); + --primary: oklch(0.58 0.23 322); + --primary-foreground: oklch(0.99 0.01 322); + --secondary: oklch(0.94 0.02 300); + --secondary-foreground: oklch(0.3 0.05 300); + --muted: oklch(0.95 0.015 300); + --muted-foreground: oklch(0.5 0.03 290); + --accent: oklch(0.9 0.06 330); + --accent-foreground: oklch(0.3 0.1 330); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.9 0.02 300); + --input: oklch(0.9 0.02 300); + --ring: oklch(0.58 0.23 322); + --chart-1: oklch(0.72 0.2 350); + --chart-2: oklch(0.62 0.24 320); + --chart-3: oklch(0.58 0.22 280); + --chart-4: oklch(0.6 0.2 250); + --chart-5: oklch(0.68 0.15 220); + --radius: 0.9rem; + --sidebar: oklch(0.97 0.01 300); + --sidebar-foreground: oklch(0.22 0.03 290); + --sidebar-primary: oklch(0.58 0.23 322); + --sidebar-primary-foreground: oklch(0.99 0.01 322); + --sidebar-accent: oklch(0.9 0.06 330); + --sidebar-accent-foreground: oklch(0.3 0.1 330); + --sidebar-border: oklch(0.9 0.02 300); + --sidebar-ring: oklch(0.58 0.23 322); +} + +.dark { + --background: oklch(0.16 0.025 285); + --foreground: oklch(0.95 0.015 290); + --card: oklch(0.21 0.03 285); + --card-foreground: oklch(0.95 0.015 290); + --popover: oklch(0.19 0.03 285); + --popover-foreground: oklch(0.95 0.015 290); + --primary: oklch(0.7 0.2 322); + --primary-foreground: oklch(0.15 0.03 322); + --secondary: oklch(0.28 0.04 285); + --secondary-foreground: oklch(0.92 0.02 290); + --muted: oklch(0.26 0.03 285); + --muted-foreground: oklch(0.68 0.03 290); + --accent: oklch(0.35 0.09 330); + --accent-foreground: oklch(0.95 0.03 330); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.7 0.2 322); + --chart-1: oklch(0.75 0.19 350); + --chart-2: oklch(0.7 0.22 320); + --chart-3: oklch(0.68 0.2 280); + --chart-4: oklch(0.68 0.19 250); + --chart-5: oklch(0.72 0.15 220); + --sidebar: oklch(0.19 0.03 285); + --sidebar-foreground: oklch(0.95 0.015 290); + --sidebar-primary: oklch(0.7 0.2 322); + --sidebar-primary-foreground: oklch(0.15 0.03 322); + --sidebar-accent: oklch(0.35 0.09 330); + --sidebar-accent-foreground: oklch(0.95 0.03 330); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.7 0.2 322); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* Signature pink -> purple -> blue sweep, used sparingly (primary CTAs, + active rings, headline accents) rather than flooding every surface. */ +.bp-gradient-text { + background: linear-gradient( + 100deg, + oklch(0.75 0.19 350), + oklch(0.65 0.23 322), + oklch(0.62 0.2 260) + ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} + +.bp-gradient-ring { + background: linear-gradient( + 135deg, + oklch(0.75 0.19 350), + oklch(0.65 0.23 322), + oklch(0.62 0.2 260) + ); +} + +/* Glassy translucent card surface, subtle glow on hover. */ +.bp-glass { + background: color-mix(in oklch, var(--card) 72%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; +} +.bp-glass:hover { + border-color: color-mix(in oklch, var(--primary) 45%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 25%, transparent), 0 10px 30px -12px rgba(0, 0, 0, 0.45); +} + +@keyframes bp-pulse-glow { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent); + } +} +.bp-pulse { + animation: bp-pulse-glow 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .bp-pulse { + animation: none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2ec9531 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Geist, Space_Grotesk } from "next/font/google"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/layout/ThemeProvider"; +import { Toaster } from "@/components/ui/sonner"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); +const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" }); + +export const metadata: Metadata = { + title: "sexy", + description: "Bluetooth toy control console", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..e57b82a --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { LoginForm } from "@/components/auth/LoginForm"; + +export default function LoginPage() { + return ( +
+ + + sexy + Enter the shared access secret to continue. + + + + + + + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..02e61e0 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 0000000..4a61215 --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [secret, setSecret] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret }), + }); + + if (!res.ok) { + setSubmitting(false); + setError("Incorrect secret."); + return; + } + + router.push(searchParams.get("from") ?? "/"); + router.refresh(); + } + + return ( +
+
+ + setSecret(e.target.value)} + placeholder="••••••••" + /> +
+ {error &&

{error}

} + +
+ ); +} diff --git a/components/control/ActuatorSlider.tsx b/components/control/ActuatorSlider.tsx new file mode 100644 index 0000000..a65c2f2 --- /dev/null +++ b/components/control/ActuatorSlider.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Slider } from "@/components/ui/slider"; +import type { ActuatorInfo } from "@/lib/buttplug/types"; + +interface ActuatorSliderProps { + actuator: ActuatorInfo; + value: number; + transmitting: boolean; + onChange: (value: number) => void; +} + +export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) { + return ( +
+
+ {actuator.descriptor} + + {Math.round(value * 100)}% + +
+ onChange(v / 100)} + /> +
+ ); +} diff --git a/components/control/ButtplugConsole.tsx b/components/control/ButtplugConsole.tsx new file mode 100644 index 0000000..d2b217d --- /dev/null +++ b/components/control/ButtplugConsole.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + disconnectAll, + getButtplugClientHandle, + getDevice, + isWebBluetoothSupported, + startScanning, + stopScanning, +} from "@/lib/buttplug/client"; +import { buildOutputCommand, findFeature } from "@/lib/buttplug/commands"; +import type { ButtplugRuntime } from "@/lib/buttplug/commands"; +import { throttledSend } from "@/lib/buttplug/throttle"; +import { eventBuffer } from "@/lib/buttplug/event-buffer"; +import { actuatorKey, useButtplugStore } from "@/lib/buttplug/store"; +import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; +import { DeviceScanPanel } from "./DeviceScanPanel"; +import { DeviceCard } from "./DeviceCard"; +import { RecordControls } from "./RecordControls"; +import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog"; +import { Card, CardContent } from "@/components/ui/card"; + +interface ActivePlaySession { + id: number; + startedAt: number; + sessionDeviceIdByDeviceIndex: Map; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function ButtplugConsole() { + const supported = isWebBluetoothSupported(); + const connected = useButtplugStore((s) => s.connected); + const scanning = useButtplugStore((s) => s.scanning); + const devices = useButtplugStore((s) => s.devices); + const actuatorValues = useButtplugStore((s) => s.actuatorValues); + const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); + const storeError = useButtplugStore((s) => s.error); + + const [activeSession, setActiveSession] = useState(null); + const [elapsedMs, setElapsedMs] = useState(0); + const [sessionBusy, setSessionBusy] = useState(false); + const [savePromptSessionId, setSavePromptSessionId] = useState(null); + const [transmittingKey, setTransmittingKey] = useState(null); + + const runtimeRef = useRef(null); + + useEffect(() => { + if (!activeSession) return; + const interval = setInterval(() => setElapsedMs(Date.now() - activeSession.startedAt), 250); + return () => clearInterval(interval); + }, [activeSession]); + + useEffect(() => { + return () => { + void disconnectAll(); + }; + }, []); + + async function ensureRuntime(): Promise { + if (runtimeRef.current) return runtimeRef.current; + const { runtime } = await getButtplugClientHandle(); + runtimeRef.current = runtime; + return runtime; + } + + async function handleScan() { + try { + await ensureRuntime(); + await startScanning(); + } catch { + toast.error("Could not start scanning - check Bluetooth is on and permitted."); + } + } + + async function handleStartSession() { + setSessionBusy(true); + try { + const deviceList = Object.values(devices); + const body = { + kind: "live" as const, + devices: deviceList.map((d) => ({ + slotLabel: d.displayName ?? d.name, + bleName: d.name, + capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length }, + })), + }; + const res = await fetch("/api/play-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error("failed to start session"); + const data: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } = + await res.json(); + + const sessionDeviceIdByDeviceIndex = new Map(); + deviceList.forEach((d, i) => { + const row = data.sessionDevices[i]; + if (row) sessionDeviceIdByDeviceIndex.set(d.index, row.id); + }); + + eventBuffer.start(data.session.id, data.session.startedAt); + sessionDeviceIdByDeviceIndex.forEach((sessionDeviceId, deviceIndex) => { + eventBuffer.registerSessionDevice(deviceIndex, sessionDeviceId); + }); + + setActiveSession({ + id: data.session.id, + startedAt: data.session.startedAt, + sessionDeviceIdByDeviceIndex, + }); + setElapsedMs(0); + } catch { + toast.error("Could not start session."); + } finally { + setSessionBusy(false); + } + } + + async function handleEndSession() { + if (!activeSession) return; + setSessionBusy(true); + eventBuffer.stop(); + await fetch(`/api/play-sessions/${activeSession.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "completed" }), + }); + setSavePromptSessionId(activeSession.id); + setActiveSession(null); + setSessionBusy(false); + } + + async function handleActuatorChange(device: ConnectedDeviceInfo, featureIndex: number, value: number) { + const key = actuatorKey(device.index, featureIndex); + setActuatorValue(device.index, featureIndex, value); + + const actuator = device.actuators.find((a) => a.featureIndex === featureIndex); + if (!actuator) return; + + throttledSend( + key, + async (v) => { + try { + const runtime = await ensureRuntime(); + const liveDevice = await getDevice(device.index); + const feature = liveDevice && findFeature(liveDevice, featureIndex); + if (!liveDevice || !feature) return; + setTransmittingKey(key); + const cmd = buildOutputCommand(runtime, actuator, v); + await feature.runOutput(cmd); + eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v }); + } finally { + setTransmittingKey((k) => (k === key ? null : k)); + } + }, + value, + ); + } + + async function handleStopDevice(device: ConnectedDeviceInfo) { + const liveDevice = await getDevice(device.index); + await liveDevice?.stop(); + eventBuffer.record({ deviceIndex: device.index, commandType: "stop", featureIndex: 0, value: 0 }); + device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0)); + } + + if (!supported) { + return ( + + + This browser doesn't support Web Bluetooth, so device control isn't available here. Use a + Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can't run + Web Bluetooth at all, on any browser - this is an Apple platform limitation, not a bug. + + + ); + } + + return ( +
+
+ void stopScanning()} /> + +
+ + {storeError &&

{storeError}

} + + {Object.keys(devices).length === 0 ? ( + + + No devices connected yet. Scan to discover nearby toys, then select one from your browser's + pairing prompt. + + + ) : ( +
+ {Object.values(devices).map((device) => ( + [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]), + )} + transmittingFeatureIndex={ + transmittingKey?.startsWith(`${device.index}:`) + ? Number(transmittingKey.split(":")[1]) + : null + } + onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)} + onStop={() => void handleStopDevice(device)} + /> + ))} +
+ )} + + setSavePromptSessionId(null)} + onSaved={() => setSavePromptSessionId(null)} + /> +
+ ); +} diff --git a/components/control/ButtplugConsoleLoader.tsx b/components/control/ButtplugConsoleLoader.tsx new file mode 100644 index 0000000..a1b137b --- /dev/null +++ b/components/control/ButtplugConsoleLoader.tsx @@ -0,0 +1,13 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Skeleton } from "@/components/ui/skeleton"; + +const ButtplugConsole = dynamic(() => import("./ButtplugConsole").then((m) => m.ButtplugConsole), { + ssr: false, + loading: () => , +}); + +export function ButtplugConsoleLoader() { + return ; +} diff --git a/components/control/DeviceCard.tsx b/components/control/DeviceCard.tsx new file mode 100644 index 0000000..f627e82 --- /dev/null +++ b/components/control/DeviceCard.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ActuatorSlider } from "./ActuatorSlider"; +import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; + +interface DeviceCardProps { + device: ConnectedDeviceInfo; + actuatorValues: Record; + transmittingFeatureIndex: number | null; + onActuatorChange: (featureIndex: number, value: number) => void; + onStop: () => void; +} + +export function DeviceCard({ + device, + actuatorValues, + transmittingFeatureIndex, + onActuatorChange, + onStop, +}: DeviceCardProps) { + return ( + + + {device.displayName ?? device.name} + + + + {device.actuators.length === 0 ? ( +

No controllable actuators reported.

+ ) : ( + device.actuators.map((actuator) => ( + onActuatorChange(actuator.featureIndex, value)} + /> + )) + )} +
+
+ ); +} diff --git a/components/control/DeviceScanPanel.tsx b/components/control/DeviceScanPanel.tsx new file mode 100644 index 0000000..31af85a --- /dev/null +++ b/components/control/DeviceScanPanel.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Bluetooth, LoaderCircle } from "lucide-react"; + +interface DeviceScanPanelProps { + scanning: boolean; + onScan: () => void; + onStopScan: () => void; +} + +export function DeviceScanPanel({ scanning, onScan, onStopScan }: DeviceScanPanelProps) { + return ( + + ); +} diff --git a/components/control/RecordControls.tsx b/components/control/RecordControls.tsx new file mode 100644 index 0000000..48b2f68 --- /dev/null +++ b/components/control/RecordControls.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Circle, Square } from "lucide-react"; + +interface RecordControlsProps { + active: boolean; + elapsedLabel: string; + disabled: boolean; + busy: boolean; + onStart: () => void; + onEnd: () => void; +} + +export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) { + if (active) { + return ( +
+ + + Session live · {elapsedLabel} + + +
+ ); + } + + return ( + + ); +} diff --git a/components/devices/DevicesTable.tsx b/components/devices/DevicesTable.tsx new file mode 100644 index 0000000..0764bff --- /dev/null +++ b/components/devices/DevicesTable.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +export interface DeviceRow { + id: number; + displayName: string | null; + bleName: string; + lastConnectedAt: number | null; +} + +function DeviceNameCell({ device }: { device: DeviceRow }) { + const router = useRouter(); + const [value, setValue] = useState(device.displayName ?? device.bleName); + const [saving, setSaving] = useState(false); + + async function handleSave() { + if (value.trim().length === 0) return; + setSaving(true); + const res = await fetch(`/api/devices/${device.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: value.trim() }), + }); + setSaving(false); + if (res.ok) { + toast.success("Renamed"); + router.refresh(); + } else { + toast.error("Could not rename device"); + } + } + + return ( +
+ setValue(e.target.value)} className="h-8 max-w-48" /> + +
+ ); +} + +export function DevicesTable({ devices }: { devices: DeviceRow[] }) { + if (devices.length === 0) { + return

No devices seen yet - connect one from the Control page.

; + } + + return ( + + + + Display name + Advertised name + Last connected + + + + {devices.map((d) => ( + + + + + {d.bleName} + + {d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"} + + + ))} + +
+ ); +} diff --git a/components/layout/ConnectionStatus.tsx b/components/layout/ConnectionStatus.tsx new file mode 100644 index 0000000..b1f9702 --- /dev/null +++ b/components/layout/ConnectionStatus.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useButtplugStore } from "@/lib/buttplug/store"; +import { cn } from "@/lib/utils"; + +export function ConnectionStatus() { + const connected = useButtplugStore((s) => s.connected); + const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length); + + return ( +
+ + + {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "Not connected"} + +
+ ); +} diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx new file mode 100644 index 0000000..49e3b3d --- /dev/null +++ b/components/layout/NavBar.tsx @@ -0,0 +1,97 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Menu, Moon, Sun, LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { ConnectionStatus } from "./ConnectionStatus"; +import { cn } from "@/lib/utils"; + +const LINKS = [ + { href: "/", label: "Dashboard" }, + { href: "/control", label: "Control" }, + { href: "/recordings", label: "Recordings" }, + { href: "/sessions", label: "Sessions" }, + { href: "/stats", label: "Stats" }, + { href: "/devices", label: "Devices" }, +]; + +function NavLinks({ onNavigate }: { onNavigate?: () => void }) { + const pathname = usePathname(); + return ( + <> + {LINKS.map((link) => ( + + {link.label} + + ))} + + ); +} + +export function NavBar() { + const { theme, setTheme } = useTheme(); + const router = useRouter(); + + async function handleLogout() { + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/login"); + router.refresh(); + } + + return ( +
+
+ + sexy + + + + +
+ + + + + + + + + + Menu + + + +
+
+
+ ); +} diff --git a/components/layout/ThemeProvider.tsx b/components/layout/ThemeProvider.tsx new file mode 100644 index 0000000..2e260ce --- /dev/null +++ b/components/layout/ThemeProvider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type { ComponentProps } from "react"; + +export function ThemeProvider({ children, ...props }: ComponentProps) { + return {children}; +} diff --git a/components/recordings/DeviceRemapDialog.tsx b/components/recordings/DeviceRemapDialog.tsx new file mode 100644 index 0000000..ab4e779 --- /dev/null +++ b/components/recordings/DeviceRemapDialog.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap"; +import type { RecordingDeviceSlot } from "@/lib/db/schema"; +import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; + +interface DeviceRemapDialogProps { + open: boolean; + deviceSlots: RecordingDeviceSlot[]; + connectedDevices: ConnectedDeviceInfo[]; + onCancel: () => void; + onConfirm: (mapping: Map) => void; +} + +export function DeviceRemapDialog({ + open, + deviceSlots, + connectedDevices, + onCancel, + onConfirm, +}: DeviceRemapDialogProps) { + const autoMapped = autoMapDeviceSlots(deviceSlots, connectedDevices); + const [assignments, setAssignments] = useState>( + Object.fromEntries(deviceSlots.map((slot, i) => [slot.sourceSessionDeviceId, autoMapped[i]?.matchedDeviceIndex ?? null])), + ); + + const allAssigned = deviceSlots.every((s) => assignments[s.sourceSessionDeviceId] !== null); + + function handleConfirm() { + const mapping = new Map(); + for (const slot of deviceSlots) { + const deviceIndex = assignments[slot.sourceSessionDeviceId]; + if (deviceIndex !== null && deviceIndex !== undefined) mapping.set(slot.sourceSessionDeviceId, deviceIndex); + } + onConfirm(mapping); + } + + return ( + !o && onCancel()}> + + + Match devices for replay + + Web Bluetooth doesn't expose a stable device id across sessions, so match each recorded + device to a currently-connected one. Two identically-named devices can't be told apart + automatically. + + +
+ {deviceSlots.map((slot) => ( +
+ {slot.slotLabel} + +
+ ))} +
+ + + + +
+
+ ); +} diff --git a/components/recordings/RecordingsTable.tsx b/components/recordings/RecordingsTable.tsx new file mode 100644 index 0000000..589a323 --- /dev/null +++ b/components/recordings/RecordingsTable.tsx @@ -0,0 +1,89 @@ +"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 ( +

+ No recordings yet - start a session on the Control page and save it when you're done. +

+ ); + } + + return ( + + + + Name + Duration + Plays + Last played + Actions + + + + {recordings.map((r) => ( + + + + {r.name} + + + {formatDuration(r.durationMs)} + + {r.playCount} + + + {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} + + + + + + + ))} + +
+ ); +} diff --git a/components/recordings/ReplayPlayer.tsx b/components/recordings/ReplayPlayer.tsx new file mode 100644 index 0000000..cef166c --- /dev/null +++ b/components/recordings/ReplayPlayer.tsx @@ -0,0 +1,193 @@ +"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(null); + const [showRemap, setShowRemap] = useState(false); + const [player, setPlayer] = useState(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) { + 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(); + 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( + 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

Loading recording...

; + } + + return ( +
+ + + {data.recording.name} + + + {!player ? ( + <> +

+ Connect the devices you want to replay onto, then match them to the recording's + original device slots. +

+
+ void startScanning()} onStopScan={() => void stopScanning()} /> + +
+ + ) : ( +
+ player.seek(v)} + /> +
+ + {formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)} + + +
+
+ )} +
+
+ + setShowRemap(false)} + onConfirm={(mapping) => void handleConfirmRemap(mapping)} + /> + + +
+ ); +} diff --git a/components/recordings/ReplayPlayerLoader.tsx b/components/recordings/ReplayPlayerLoader.tsx new file mode 100644 index 0000000..3a769c8 --- /dev/null +++ b/components/recordings/ReplayPlayerLoader.tsx @@ -0,0 +1,13 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Skeleton } from "@/components/ui/skeleton"; + +const ReplayPlayer = dynamic(() => import("./ReplayPlayer").then((m) => m.ReplayPlayer), { + ssr: false, + loading: () => , +}); + +export function ReplayPlayerLoader({ recordingId }: { recordingId: number }) { + return ; +} diff --git a/components/recordings/SaveRecordingDialog.tsx b/components/recordings/SaveRecordingDialog.tsx new file mode 100644 index 0000000..8f6fbbd --- /dev/null +++ b/components/recordings/SaveRecordingDialog.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; + +interface SaveRecordingDialogProps { + playSessionId: number | null; + onClose: () => void; + onSaved: () => void; +} + +export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRecordingDialogProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [saving, setSaving] = useState(false); + + async function handleSave() { + if (!playSessionId || name.trim().length === 0) return; + setSaving(true); + const res = await fetch("/api/recordings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourcePlaySessionId: playSessionId, name: name.trim(), description }), + }); + setSaving(false); + if (res.ok) { + toast.success("Recording saved"); + onSaved(); + } else { + toast.error("Could not save recording"); + } + } + + return ( + !open && onClose()}> + + + Save session as a recording + Give it a name so you can find and replay it later. + +
+
+ + setName(e.target.value)} autoFocus /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + + + +
+
+ ); +} diff --git a/components/sessions/SessionTimelineChart.tsx b/components/sessions/SessionTimelineChart.tsx new file mode 100644 index 0000000..0e829dc --- /dev/null +++ b/components/sessions/SessionTimelineChart.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"; + +export interface TimelineRow { + bucket: number; + sessionDeviceId: number; + slotLabel: string; + avgValue: number; + maxValue: number; +} + +const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; + +// Chart config keys become raw CSS custom-property names (--color-), so +// they must be safe identifiers - a user-chosen device display name isn't. +// Use the numeric session_device_id as the key, slotLabel only as display text. +function seriesKey(sessionDeviceId: number): string { + return `device_${sessionDeviceId}`; +} + +export function SessionTimelineChart({ timeline }: { timeline: TimelineRow[] }) { + const series = [...new Map(timeline.map((r) => [r.sessionDeviceId, r.slotLabel])).entries()]; + const buckets = [...new Set(timeline.map((r) => r.bucket))].sort((a, b) => a - b); + + const data = buckets.map((bucket) => { + const row: Record = { bucket }; + for (const [sessionDeviceId] of series) { + const match = timeline.find((r) => r.bucket === bucket && r.sessionDeviceId === sessionDeviceId); + row[seriesKey(sessionDeviceId)] = match ? Math.round(match.avgValue * 100) : 0; + } + return row; + }); + + const config: ChartConfig = Object.fromEntries( + series.map(([sessionDeviceId, slotLabel], i) => [ + seriesKey(sessionDeviceId), + { label: slotLabel, color: SERIES_COLORS[i % SERIES_COLORS.length] }, + ]), + ); + + if (timeline.length === 0) { + return

No device activity recorded for this session.

; + } + + return ( + + + + `${Math.round(v / 1000)}s`} + tickLine={false} + axisLine={false} + /> + + } /> + {series.map(([sessionDeviceId]) => ( + + ))} + + + ); +} diff --git a/components/sessions/SessionsTable.tsx b/components/sessions/SessionsTable.tsx new file mode 100644 index 0000000..8d5715e --- /dev/null +++ b/components/sessions/SessionsTable.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +export interface SessionRow { + id: number; + name: string | null; + kind: "live" | "replay"; + status: "active" | "completed" | "aborted"; + startedAt: number; + durationMs: number | null; +} + +function formatDuration(ms: number | null): string { + if (ms === null) return "-"; + 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 SessionsTable({ sessions }: { sessions: SessionRow[] }) { + const router = useRouter(); + + async function handleDelete(id: number) { + const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" }); + if (res.ok) { + toast.success("Session deleted"); + router.refresh(); + } else if (res.status === 409) { + toast.error("A saved recording still references this session"); + } else { + toast.error("Could not delete session"); + } + } + + if (sessions.length === 0) { + return

No sessions yet.

; + } + + return ( + + + + Session + Kind + Status + Started + Duration + Actions + + + + {sessions.map((s) => ( + + + + {s.name ?? `Session #${s.id}`} + + + + {s.kind} + + {s.status} + {new Date(s.startedAt).toLocaleString()} + {formatDuration(s.durationMs)} + + + + + ))} + +
+ ); +} diff --git a/components/stats/DeviceUsageTable.tsx b/components/stats/DeviceUsageTable.tsx new file mode 100644 index 0000000..d1b73ef --- /dev/null +++ b/components/stats/DeviceUsageTable.tsx @@ -0,0 +1,44 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; + +export interface DeviceUsageRow { + deviceId: number; + displayName: string | null; + bleName: string; + sessionCount: number; + totalActiveMs: number; + commandCount: number; + lastUsedAt: number | null; +} + +export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) { + if (devices.length === 0) { + return

No device activity yet.

; + } + + return ( + + + + Device + Sessions + Active time + Commands + Last used + + + + {devices.map((d) => ( + + {d.displayName ?? d.bleName} + {d.sessionCount} + {(d.totalActiveMs / 60_000).toFixed(1)}m + {d.commandCount} + + {d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"} + + + ))} + +
+ ); +} diff --git a/components/stats/RecordingLibraryStats.tsx b/components/stats/RecordingLibraryStats.tsx new file mode 100644 index 0000000..bf9a695 --- /dev/null +++ b/components/stats/RecordingLibraryStats.tsx @@ -0,0 +1,61 @@ +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 ( +
+
+ + + Recordings + + {stats.count} + + + + Avg length + + + {Math.round(stats.avgDurationMs / 1000)}s + + + + + Total plays + + {stats.totalPlayCount} + +
+ + {stats.list.length > 0 && ( + + + + Recording + Plays + Last played + + + + {stats.list.map((r) => ( + + {r.name} + {r.playCount} + + {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} + + + ))} + +
+ )} +
+ ); +} diff --git a/components/stats/SessionsSummaryCards.tsx b/components/stats/SessionsSummaryCards.tsx new file mode 100644 index 0000000..38da728 --- /dev/null +++ b/components/stats/SessionsSummaryCards.tsx @@ -0,0 +1,63 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export interface SessionsSummary { + count: number; + totalDurationMs: number; + avgDurationMs: number; + byKind: { kind: "live" | "replay"; count: number; totalDurationMs: number }[]; + durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[]; +} + +function formatHours(ms: number): string { + return `${(ms / 3_600_000).toFixed(1)}h`; +} + +export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) { + const liveCount = summary.byKind.find((k) => k.kind === "live")?.count ?? 0; + const replayCount = summary.byKind.find((k) => k.kind === "replay")?.count ?? 0; + + return ( +
+
+ + + Completed sessions + + {summary.count} + + + + Total time + + {formatHours(summary.totalDurationMs)} + + + + Avg session length + + + {Math.round(summary.avgDurationMs / 60_000)}m + + +
+

+ {liveCount} live · {replayCount} replay +

+ {summary.durationPerDevice.length > 0 && ( + + + Duration per device + + + {summary.durationPerDevice.map((d) => ( +
+ {d.displayName ?? d.bleName} + {formatHours(d.totalActiveMs)} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..cacff11 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..75b8c3d --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..4458dae --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..7c2dc84 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +