Initial implementation of Bluetooth toy control app
CI / Static checks (push) Successful in 1m27s
CI / Build and push image (push) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
2026-08-25 07:51:38 +02:00
co-authored by Claude Sonnet 5
commit 1119c8eea0
112 changed files with 15522 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
data
*.log
.DS_Store
.env*
+24
View File
@@ -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
+64
View File
@@ -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
+20
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+54
View File
@@ -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"]
+21
View File
@@ -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.
+147
View File
@@ -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).
+16
View File
@@ -0,0 +1,16 @@
import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader";
export default function ControlPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Control</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<ButtplugConsole />
</div>
);
}
+26
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Devices</h1>
<p className="text-sm text-muted-foreground">Devices seen across past sessions. Give them friendlier names.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Known devices</CardTitle>
</CardHeader>
<CardContent>
<DevicesTable devices={devices} />
</CardContent>
</Card>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { NavBar } from "@/components/layout/NavBar";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-dvh">
<NavBar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</div>
);
}
+103
View File
@@ -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 (
<div className="flex flex-col gap-8">
<Card className="bp-glass overflow-hidden">
<CardContent className="flex flex-col items-start gap-4 py-8">
<h1 className="font-display bp-gradient-text text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground">
Scan for nearby devices, take control, and record sessions to replay later - all running
directly from your browser over Web Bluetooth.
</p>
<Button asChild size="lg">
<Link href="/control">Start a session</Link>
</Button>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{recordings.length}</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent recordings</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{recentRecordings.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing saved yet.</p>
)}
{recentRecordings.map((r) => (
<Link
key={r.id}
href={`/recordings/${r.id}`}
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted"
>
<span>{r.name}</span>
<span className="text-muted-foreground">{r.playCount} plays</span>
</Link>
))}
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent sessions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentSessions.map((s) => (
<Link
key={s.id}
href={`/sessions/${s.id}`}
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted"
>
<span>{s.name ?? `Session #${s.id}`}</span>
<span className="text-muted-foreground">{new Date(s.startedAt).toLocaleDateString()}</span>
</Link>
))}
</CardContent>
</Card>
</div>
</div>
);
}
+74
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display 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
@@ -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 (
<div className="flex flex-col gap-6">
<h1 className="font-display text-2xl font-semibold">Replay</h1>
<ReplayPlayerLoader recordingId={Number(id)} />
</div>
);
}
+26
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display 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>
);
}
+53
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
<p className="text-sm text-muted-foreground">
{detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)}
</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Intensity timeline</CardTitle>
</CardHeader>
<CardContent>
<SessionTimelineChart timeline={timeline} />
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Devices</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{detail.devices.map((d) => (
<div key={d.id} className="text-sm">
{d.slotLabel} <span className="text-muted-foreground">({d.deviceDisplayName ?? d.deviceBleName})</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Sessions</h1>
<p className="text-sm text-muted-foreground">History of live control and replay sessions.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
</CardHeader>
<CardContent>
<SessionsTable sessions={sessions} />
</CardContent>
</Card>
</div>
);
}
+45
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Stats</h1>
<p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p>
</div>
<Tabs defaultValue="sessions">
<TabsList>
<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>
);
}
+23
View File
@@ -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;
}
+8
View File
@@ -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;
}
+16
View File
@@ -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 });
}
+7
View File
@@ -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 });
}
+11
View File
@@ -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 });
}
}
@@ -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 });
}
+40
View File
@@ -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;
}
}
+31
View File
@@ -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 });
}
+32
View File
@@ -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 });
}
+23
View File
@@ -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 });
}
+9
View File
@@ -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 });
}
+7
View File
@@ -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);
}
@@ -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 });
}
+7
View File
@@ -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);
}
+194
View File
@@ -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;
}
}
+31
View File
@@ -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 (
<html
lang="en"
suppressHydrationWarning
className={cn("font-sans", geist.variable, spaceGrotesk.variable)}
>
<body>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+21
View File
@@ -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 (
<div className="flex min-h-dvh items-center justify-center p-4">
<Card className="bp-glass w-full max-w-sm">
<CardHeader>
<CardTitle className="font-display bp-gradient-text text-2xl">sexy</CardTitle>
<CardDescription>Enter the shared access secret to continue.</CardDescription>
</CardHeader>
<CardContent>
<Suspense>
<LoginForm />
</Suspense>
</CardContent>
</Card>
</div>
);
}
+25
View File
@@ -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": {}
}
+57
View File
@@ -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<string | null>(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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="secret">Access secret</Label>
<Input
id="secret"
type="password"
autoFocus
autoComplete="current-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={submitting || secret.length === 0} className="w-full">
{submitting ? "Checking..." : "Enter"}
</Button>
</form>
);
}
+31
View File
@@ -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 (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{actuator.descriptor}</span>
<span className={transmitting ? "bp-gradient-text font-semibold" : "text-muted-foreground"}>
{Math.round(value * 100)}%
</span>
</div>
<Slider
value={[value * 100]}
min={0}
max={100}
step={1}
onValueChange={([v]) => onChange(v / 100)}
/>
</div>
);
}
+239
View File
@@ -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<number, number>;
}
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<ActivePlaySession | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [sessionBusy, setSessionBusy] = useState(false);
const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null);
const [transmittingKey, setTransmittingKey] = useState<string | null>(null);
const runtimeRef = useRef<ButtplugRuntime | null>(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<ButtplugRuntime> {
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<number, number>();
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 (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">
This browser doesn&apos;t support Web Bluetooth, so device control isn&apos;t available here. Use a
Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can&apos;t run
Web Bluetooth at all, on any browser - this is an Apple platform limitation, not a bug.
</CardContent>
</Card>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
<RecordControls
active={activeSession !== null}
elapsedLabel={formatElapsed(elapsedMs)}
disabled={!connected || Object.keys(devices).length === 0}
busy={sessionBusy}
onStart={handleStartSession}
onEnd={handleEndSession}
/>
</div>
{storeError && <p className="text-sm text-destructive">{storeError}</p>}
{Object.keys(devices).length === 0 ? (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then select one from your browser&apos;s
pairing prompt.
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{Object.values(devices).map((device) => (
<DeviceCard
key={device.index}
device={device}
actuatorValues={Object.fromEntries(
device.actuators.map((a) => [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)}
/>
))}
</div>
)}
<SaveRecordingDialog
playSessionId={savePromptSessionId}
onClose={() => setSavePromptSessionId(null)}
onSaved={() => setSavePromptSessionId(null)}
/>
</div>
);
}
@@ -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: () => <Skeleton className="h-64 w-full" />,
});
export function ButtplugConsoleLoader() {
return <ButtplugConsole />;
}
+48
View File
@@ -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<number, number>;
transmittingFeatureIndex: number | null;
onActuatorChange: (featureIndex: number, value: number) => void;
onStop: () => void;
}
export function DeviceCard({
device,
actuatorValues,
transmittingFeatureIndex,
onActuatorChange,
onStop,
}: DeviceCardProps) {
return (
<Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{device.displayName ?? device.name}</CardTitle>
<Button variant="outline" size="sm" onClick={onStop}>
Stop
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{device.actuators.length === 0 ? (
<p className="text-sm text-muted-foreground">No controllable actuators reported.</p>
) : (
device.actuators.map((actuator) => (
<ActuatorSlider
key={actuator.featureIndex}
actuator={actuator}
value={actuatorValues[actuator.featureIndex] ?? 0}
transmitting={transmittingFeatureIndex === actuator.featureIndex}
onChange={(value) => onActuatorChange(actuator.featureIndex, value)}
/>
))
)}
</CardContent>
</Card>
);
}
+26
View File
@@ -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 (
<Button onClick={scanning ? onStopScan : onScan} variant={scanning ? "outline" : "default"}>
{scanning ? (
<>
<LoaderCircle className="size-4 animate-spin" /> Stop scanning
</>
) : (
<>
<Bluetooth className="size-4" /> Scan for devices
</>
)}
</Button>
);
}
+35
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className="flex items-center gap-2 text-sm font-medium">
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel}
</span>
<Button variant="outline" size="sm" onClick={onEnd} disabled={busy}>
<Square className="size-3.5" /> End session
</Button>
</div>
);
}
return (
<Button onClick={onStart} disabled={disabled || busy} size="sm">
<Circle className="size-3.5" /> Start session
</Button>
);
}
+78
View File
@@ -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 (
<div className="flex items-center gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 max-w-48" />
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving}>
Save
</Button>
</div>
);
}
export function DevicesTable({ devices }: { devices: DeviceRow[] }) {
if (devices.length === 0) {
return <p className="text-sm text-muted-foreground">No devices seen yet - connect one from the Control page.</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Display name</TableHead>
<TableHead>Advertised name</TableHead>
<TableHead>Last connected</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((d) => (
<TableRow key={d.id}>
<TableCell>
<DeviceNameCell device={d} />
</TableCell>
<TableCell className="text-muted-foreground">{d.bleName}</TableCell>
<TableCell className="text-muted-foreground">
{d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
+23
View File
@@ -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 (
<div className="flex items-center gap-2 rounded-full border border-border bg-card/60 px-3 py-1 text-xs">
<span
className={cn(
"size-2 rounded-full",
connected ? "bg-primary bp-pulse" : "bg-muted-foreground/50",
)}
/>
<span className="text-muted-foreground">
{connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "Not connected"}
</span>
</div>
);
}
+97
View File
@@ -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
key={link.href}
href={link.href}
onClick={onNavigate}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
pathname === link.href
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{link.label}
</Link>
))}
</>
);
}
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 (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4">
<Link href="/" className="font-display bp-gradient-text mr-2 text-lg font-semibold">
sexy
</Link>
<nav className="hidden items-center gap-1 md:flex">
<NavLinks />
</nav>
<div className="ml-auto flex items-center gap-2">
<ConnectionStatus />
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="hidden size-4 dark:block" />
<Moon className="block size-4 dark:hidden" />
</Button>
<Button variant="ghost" size="icon" aria-label="Log out" onClick={handleLogout}>
<LogOut className="size-4" />
</Button>
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
<Menu className="size-4" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64">
<SheetTitle className="px-4 pt-4">Menu</SheetTitle>
<nav className="flex flex-col gap-1 p-4">
<NavLinks />
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -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<number, number>) => void;
}
export function DeviceRemapDialog({
open,
deviceSlots,
connectedDevices,
onCancel,
onConfirm,
}: DeviceRemapDialogProps) {
const autoMapped = autoMapDeviceSlots(deviceSlots, connectedDevices);
const [assignments, setAssignments] = useState<Record<number, number | null>>(
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<number, number>();
for (const slot of deviceSlots) {
const deviceIndex = assignments[slot.sourceSessionDeviceId];
if (deviceIndex !== null && deviceIndex !== undefined) mapping.set(slot.sourceSessionDeviceId, deviceIndex);
}
onConfirm(mapping);
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Match devices for replay</DialogTitle>
<DialogDescription>
Web Bluetooth doesn&apos;t expose a stable device id across sessions, so match each recorded
device to a currently-connected one. Two identically-named devices can&apos;t be told apart
automatically.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{deviceSlots.map((slot) => (
<div key={slot.sourceSessionDeviceId} className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{slot.slotLabel}</span>
<Select
value={assignments[slot.sourceSessionDeviceId]?.toString() ?? undefined}
onValueChange={(v) =>
setAssignments((prev) => ({ ...prev, [slot.sourceSessionDeviceId]: Number(v) }))
}
>
<SelectTrigger size="sm" className="w-48">
<SelectValue placeholder="Unmatched" />
</SelectTrigger>
<SelectContent>
{connectedDevices.map((d) => (
<SelectItem key={d.index} value={d.index.toString()}>
{d.displayName ?? d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!allAssigned}>
Start replay
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+89
View File
@@ -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 (
<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>{formatDuration(r.durationMs)}</TableCell>
<TableCell>
<Badge variant="secondary">{r.playCount}</Badge>
</TableCell>
<TableCell className="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
@@ -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<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>
);
}
@@ -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: () => <Skeleton className="h-64 w-full" />,
});
export function ReplayPlayerLoader({ recordingId }: { recordingId: number }) {
return <ReplayPlayer recordingId={recordingId} />;
}
@@ -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 (
<Dialog open={playSessionId !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save session as a recording</DialogTitle>
<DialogDescription>Give it a name so you can find and replay it later.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="recording-name">Name</Label>
<Input id="recording-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="recording-description">Description (optional)</Label>
<Input
id="recording-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Skip
</Button>
<Button onClick={handleSave} disabled={saving || name.trim().length === 0}>
{saving ? "Saving..." : "Save recording"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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-<key>), 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<string, number> = { 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 <p className="text-sm text-muted-foreground">No device activity recorded for this session.</p>;
}
return (
<ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="bucket"
tickFormatter={(v: number) => `${Math.round(v / 1000)}s`}
tickLine={false}
axisLine={false}
/>
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} />
<ChartTooltip content={<ChartTooltipContent />} />
{series.map(([sessionDeviceId]) => (
<Line
key={sessionDeviceId}
type="monotone"
dataKey={seriesKey(sessionDeviceId)}
stroke={`var(--color-${seriesKey(sessionDeviceId)})`}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ChartContainer>
);
}
+83
View File
@@ -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 <p className="text-sm text-muted-foreground">No sessions yet.</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Session</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</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="text-muted-foreground">{new Date(s.startedAt).toLocaleString()}</TableCell>
<TableCell>{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>
))}
</TableBody>
</Table>
);
}
+44
View File
@@ -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 <p className="text-sm text-muted-foreground">No device activity yet.</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Active time</TableHead>
<TableHead>Commands</TableHead>
<TableHead>Last used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((d) => (
<TableRow key={d.deviceId}>
<TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell>
<TableCell>{d.sessionCount}</TableCell>
<TableCell>{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
<TableCell>{d.commandCount}</TableCell>
<TableCell className="text-muted-foreground">
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -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 (
<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="font-display text-3xl">{stats.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle>
</CardHeader>
<CardContent className="font-display 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="font-display 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>{r.playCount}</TableCell>
<TableCell className="text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}
+63
View File
@@ -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 (
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m
</CardContent>
</Card>
</div>
<p className="text-sm text-muted-foreground">
{liveCount} live · {replayCount} replay
</p>
{summary.durationPerDevice.length > 0 && (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Duration per device</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{summary.durationPerDevice.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between text-sm">
<span>{d.displayName ?? d.bleName}</span>
<span className="text-muted-foreground">{formatHours(d.totalActiveMs)}</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
+49
View File
@@ -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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+67
View File
@@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -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 (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>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 (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+373
View File
@@ -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<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+168
View File
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+165
View File
@@ -0,0 +1,165 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { size?: "sm" | "default" }) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+59
View File
@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
View File
+42
View File
@@ -0,0 +1,42 @@
services:
sexy:
build: .
container_name: sexy
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
# Writable - the SQLite file must persist across restarts, unlike
# everything else in the container filesystem.
- ./data:/app/data
env_file: .env
networks:
- compose_network
labels:
- traefik.enable=true
- traefik.docker.network=${NETWORK_NAME}
- traefik.http.middlewares.sexy-redirect-web-secure.redirectscheme.scheme=https
- traefik.http.routers.sexy-web.rule=Host(`${TRAEFIK_HOST}`)
- traefik.http.routers.sexy-web.entrypoints=web
- traefik.http.routers.sexy-web.middlewares=sexy-redirect-web-secure
- traefik.http.routers.sexy-web-secure.rule=Host(`${TRAEFIK_HOST}`)
- traefik.http.routers.sexy-web-secure.entrypoints=web-secure
- traefik.http.routers.sexy-web-secure.tls.certresolver=resolver
- traefik.http.routers.sexy-web-secure.middlewares=security-headers@file,no-index@file
- traefik.http.services.sexy-web-secure.loadbalancer.server.port=3000
healthcheck:
test: ["CMD", "node", "healthcheck.mjs"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
networks:
compose_network:
name: ${NETWORK_NAME}
external: true
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_PATH ?? "./data/app.db",
},
});
+64
View File
@@ -0,0 +1,64 @@
CREATE TABLE `devices` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`display_name` text,
`ble_name` text NOT NULL,
`device_class` text,
`capabilities` text,
`created_at` integer NOT NULL,
`last_connected_at` integer
);
--> statement-breakpoint
CREATE TABLE `play_sessions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text,
`kind` text NOT NULL,
`replayed_recording_id` integer,
`status` text DEFAULT 'active' NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
`duration_ms` integer,
`notes` text,
FOREIGN KEY (`replayed_recording_id`) REFERENCES `recordings`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `recordings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_play_session_id` integer NOT NULL,
`name` text NOT NULL,
`description` text,
`created_at` integer NOT NULL,
`duration_ms` integer NOT NULL,
`device_slots` text NOT NULL,
`play_count` integer DEFAULT 0 NOT NULL,
`last_played_at` integer,
FOREIGN KEY (`source_play_session_id`) REFERENCES `play_sessions`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE TABLE `session_devices` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`play_session_id` integer NOT NULL,
`device_id` integer NOT NULL,
`slot_label` text NOT NULL,
`connected_at` integer NOT NULL,
`disconnected_at` integer,
FOREIGN KEY (`play_session_id`) REFERENCES `play_sessions`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `session_devices_play_session_id_idx` ON `session_devices` (`play_session_id`);--> statement-breakpoint
CREATE TABLE `session_events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`play_session_id` integer NOT NULL,
`session_device_id` integer NOT NULL,
`ts_ms` integer NOT NULL,
`command_type` text NOT NULL,
`feature_index` integer NOT NULL,
`value` real NOT NULL,
`duration_ms` integer,
`raw_payload` text,
FOREIGN KEY (`play_session_id`) REFERENCES `play_sessions`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_device_id`) REFERENCES `session_devices`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `session_events_session_ts_idx` ON `session_events` (`play_session_id`,`ts_ms`);--> statement-breakpoint
CREATE INDEX `session_events_session_device_idx` ON `session_events` (`session_device_id`);
+456
View File
@@ -0,0 +1,456 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cb592704-f845-4761-b5cc-53ec7cb5d179",
"prevId": "00000000-0000-0000-0000-000000000000",
"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
},
"replayed_recording_id": {
"name": "replayed_recording_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
}
},
"indexes": {},
"foreignKeys": {
"play_sessions_replayed_recording_id_recordings_id_fk": {
"name": "play_sessions_replayed_recording_id_recordings_id_fk",
"tableFrom": "play_sessions",
"tableTo": "recordings",
"columnsFrom": [
"replayed_recording_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"recordings": {
"name": "recordings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"source_play_session_id": {
"name": "source_play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_slots": {
"name": "device_slots",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": {
"recordings_source_play_session_id_play_sessions_id_fk": {
"name": "recordings_source_play_session_id_play_sessions_id_fk",
"tableFrom": "recordings",
"tableTo": "play_sessions",
"columnsFrom": [
"source_play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1787617808883,
"tag": "0000_bouncy_anita_blake",
"breakpoints": true
}
]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+20
View File
@@ -0,0 +1,20 @@
import http from "node:http";
const req = http.request(
{
host: "127.0.0.1",
port: process.env.PORT || 3000,
path: "/api/health",
timeout: 3000,
},
(res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
},
);
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();
+8
View File
@@ -0,0 +1,8 @@
export async function register() {
// Only the Node.js server runtime touches better-sqlite3; the Edge
// middleware runtime must never import this module.
if (process.env.NEXT_RUNTIME === "nodejs") {
const { runMigrations } = await import("@/lib/db/migrate");
runMigrations();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { SignJWT, jwtVerify } from "jose";
import { getEnv } from "@/lib/env";
export const SESSION_COOKIE_NAME = "bp_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
function signingKey(): Uint8Array {
return new TextEncoder().encode(getEnv().AUTH_SECRET);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject("app")
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
.sign(signingKey());
}
export async function verifySessionToken(token: string | undefined | null): Promise<boolean> {
if (!token) return false;
try {
await jwtVerify(token, signingKey());
return true;
} catch {
return false;
}
}
export const sessionCookieOptions = {
name: SESSION_COOKIE_NAME,
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
+14
View File
@@ -0,0 +1,14 @@
import { timingSafeEqual } from "node:crypto";
/**
* Constant-time string comparison. `timingSafeEqual` throws on mismatched
* buffer lengths, which would itself leak length via which branch throws -
* so a length mismatch is treated as a plain (also constant-time-irrelevant,
* since it never reaches the byte comparison) false rather than propagating.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
+96
View File
@@ -0,0 +1,96 @@
import type { ButtplugClient, ButtplugClientDevice } from "buttplug";
import { deriveActuators, type ButtplugRuntime } from "./commands";
import { useButtplugStore } from "./store";
import type { ConnectedDeviceInfo } from "./types";
export function isWebBluetoothSupported(): boolean {
return typeof navigator !== "undefined" && "bluetooth" in navigator;
}
interface ClientHandle {
client: ButtplugClient;
runtime: ButtplugRuntime;
}
let clientPromise: Promise<ClientHandle> | null = null;
function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo {
return {
index: device.index,
name: device.name,
displayName: device.displayName,
actuators: deriveActuators(device),
};
}
async function initClient(): Promise<ClientHandle> {
const [{ ButtplugClient, DeviceOutput, OutputType }, { ButtplugWasmClientConnector }] = await Promise.all([
import("buttplug"),
import("buttplug-wasm"),
]);
const client = new ButtplugClient("sexy");
client.on("deviceadded", (device: ButtplugClientDevice) => {
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
});
client.on("deviceremoved", (device: ButtplugClientDevice) => {
useButtplugStore.getState().removeDevice(device.index);
});
client.on("scanningfinished", () => {
useButtplugStore.getState().setScanning(false);
});
client.on("disconnect", () => {
useButtplugStore.getState().reset();
});
const connector = new ButtplugWasmClientConnector();
await client.connect(connector);
useButtplugStore.getState().setConnected(true);
for (const device of client.devices.values()) {
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
}
return { client, runtime: { DeviceOutput, OutputType } };
}
/** Lazily creates and connects the singleton Buttplug client. Browser-only. */
export function getButtplugClientHandle(): Promise<ClientHandle> {
if (typeof window === "undefined") {
return Promise.reject(new Error("Buttplug client is browser-only"));
}
if (!clientPromise) {
clientPromise = initClient().catch((err) => {
clientPromise = null;
useButtplugStore.getState().setError(err instanceof Error ? err.message : String(err));
throw err;
});
}
return clientPromise;
}
export async function startScanning(): Promise<void> {
const { client } = await getButtplugClientHandle();
useButtplugStore.getState().setScanning(true);
await client.startScanning();
}
export async function stopScanning(): Promise<void> {
const { client } = await getButtplugClientHandle();
await client.stopScanning();
useButtplugStore.getState().setScanning(false);
}
export async function disconnectAll(): Promise<void> {
if (!clientPromise) return;
const { client } = await clientPromise;
clientPromise = null;
await client.disconnect();
useButtplugStore.getState().reset();
}
export async function getDevice(deviceIndex: number): Promise<ButtplugClientDevice | undefined> {
const { client } = await getButtplugClientHandle();
return client.devices.get(deviceIndex);
}
+92
View File
@@ -0,0 +1,92 @@
import type { ButtplugClientDevice, DeviceOutputCommand, OutputType } from "buttplug";
import type { ActuatorInfo, NormalizedOutputType } from "./types";
// buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself,
// so its type is recovered from the `features` map it's stored in.
type DeviceFeature = ButtplugClientDevice["features"] extends Map<number, infer F> ? F : never;
/**
* The pieces of the dynamically-imported `buttplug` module namespace this
* file needs at runtime. Kept as a parameter (rather than a static import of
* `buttplug`'s runtime values) so this module has zero runtime dependency on
* the browser-only client library and stays safe to reference from anywhere.
*
* Pinned to buttplug@4.x's API on purpose: `buttplug-wasm` (the embedded
* Web Bluetooth connector) has not been updated for buttplug v5's breaking
* OutputCmd wire-format change, so v4 is what's actually wire-compatible
* with the WASM embedded server at runtime.
*/
export interface ButtplugRuntime {
OutputType: typeof OutputType;
DeviceOutput: {
Vibrate: { percent(p: number): DeviceOutputCommand };
Rotate: { percent(p: number): DeviceOutputCommand };
Position: { percent(p: number): DeviceOutputCommand };
HwPositionWithDuration: { percent(p: number, durationMs: number): DeviceOutputCommand };
};
}
const NORMALIZED_TO_OUTPUT: Record<NormalizedOutputType, string[]> = {
vibrate: ["Vibrate"],
rotate: ["Rotate"],
// A device may expose plain Position (no duration) or the
// duration-bearing HwPositionWithDuration - prefer whichever it reports.
linear: ["HwPositionWithDuration", "Position"],
};
/**
* Builds the actuator list for a device from its reported feature outputs.
* buttplug@4's `ButtplugClientDeviceFeature` only exposes `hasOutput`/
* `hasInput`/`runOutput`/`runInput` (no `.index`/`.descriptor` getters, added
* later in v5), so featureIndex comes from the `device.features` Map key and
* the descriptor is a synthesized label, not the device's own string.
*/
export function deriveActuators(device: ButtplugClientDevice): ActuatorInfo[] {
const actuators: ActuatorInfo[] = [];
const countByType: Partial<Record<NormalizedOutputType, number>> = {};
for (const [featureIndex, feature] of device.features.entries()) {
for (const [normalized, candidates] of Object.entries(NORMALIZED_TO_OUTPUT) as [
NormalizedOutputType,
string[],
][]) {
const matched = candidates.find((c) => feature.hasOutput(c as OutputType));
if (matched) {
const n = (countByType[normalized] ?? 0) + 1;
countByType[normalized] = n;
actuators.push({
featureIndex,
outputType: normalized,
requiresDuration: matched === "HwPositionWithDuration",
descriptor: `${normalized[0].toUpperCase()}${normalized.slice(1)} ${n}`,
});
break;
}
}
}
return actuators;
}
export function findFeature(device: ButtplugClientDevice, featureIndex: number): DeviceFeature | undefined {
return device.features.get(featureIndex);
}
/** Builds the DeviceOutputCommand for a normalized 0-1 command value. */
export function buildOutputCommand(
bp: ButtplugRuntime,
actuator: ActuatorInfo,
value: number,
durationMs?: number,
): DeviceOutputCommand {
const clamped = Math.min(1, Math.max(0, value));
switch (actuator.outputType) {
case "vibrate":
return bp.DeviceOutput.Vibrate.percent(clamped);
case "rotate":
return bp.DeviceOutput.Rotate.percent(clamped);
case "linear":
return actuator.requiresDuration
? bp.DeviceOutput.HwPositionWithDuration.percent(clamped, durationMs ?? 500)
: bp.DeviceOutput.Position.percent(clamped);
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { RecordingDeviceSlot } from "@/lib/db/schema";
import type { ConnectedDeviceInfo } from "./types";
export interface DeviceRemapEntry {
slotLabel: string;
recordedBleName: string;
matchedDeviceIndex: number | null;
}
/**
* Best-effort name match from a recording's saved device slots to the
* currently-connected devices. Web Bluetooth exposes no stable hardware id
* across sessions, so this is inherently approximate: two devices sharing an
* 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.
*/
export function autoMapDeviceSlots(
slots: RecordingDeviceSlot[],
connectedDevices: ConnectedDeviceInfo[],
): DeviceRemapEntry[] {
const usedIndexes = new Set<number>();
return slots.map((slot) => {
const normalizedSlotName = slot.recordedBleName.trim().toLowerCase();
const exact = connectedDevices.find(
(d) => !usedIndexes.has(d.index) && d.name.trim().toLowerCase() === normalizedSlotName,
);
const partial =
exact ??
connectedDevices.find(
(d) =>
!usedIndexes.has(d.index) &&
(d.name.toLowerCase().includes(normalizedSlotName) ||
normalizedSlotName.includes(d.name.toLowerCase())),
);
if (partial) usedIndexes.add(partial.index);
return {
slotLabel: slot.slotLabel,
recordedBleName: slot.recordedBleName,
matchedDeviceIndex: partial?.index ?? null,
};
});
}
+114
View File
@@ -0,0 +1,114 @@
import type { CommandEvent } from "./types";
const FLUSH_INTERVAL_MS = 4000;
interface ApiEvent {
sessionDeviceId: number;
tsMs: number;
commandType: CommandEvent["commandType"];
featureIndex: number;
value: number;
durationMs?: number;
}
/**
* Buffers every dispatched command (live or replay) and flushes it in
* batches to the play-session's events endpoint, so a dragged slider never
* fires one HTTP request per tick. Every command is always recorded here
* regardless of whether the session is later saved as a named recording -
* "recording" is a save decision made after the fact, not a separate
* capture pipeline (see lib/db/queries/recordings.ts).
*/
class EventBuffer {
private buffer: CommandEvent[] = [];
private playSessionId: number | null = null;
private sessionStartedAt = 0;
private sessionDeviceIdByDeviceIndex = new Map<number, number>();
private timer: ReturnType<typeof setInterval> | null = null;
start(playSessionId: number, sessionStartedAt: number): void {
this.playSessionId = playSessionId;
this.sessionStartedAt = sessionStartedAt;
this.sessionDeviceIdByDeviceIndex = new Map();
this.buffer = [];
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", this.flushBeacon);
document.addEventListener("visibilitychange", this.onVisibilityChange);
}
}
registerSessionDevice(deviceIndex: number, sessionDeviceId: number): void {
this.sessionDeviceIdByDeviceIndex.set(deviceIndex, sessionDeviceId);
}
record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void {
if (this.playSessionId === null) return;
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
this.buffer.push({ ...event, tsMs });
}
async flush(): Promise<void> {
if (this.buffer.length === 0 || this.playSessionId === null) return;
const events = this.drain();
try {
await fetch(`/api/play-sessions/${this.playSessionId}/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }),
keepalive: true,
});
} catch {
// Best-effort telemetry - dropping a batch on network hiccup is
// preferable to blocking or crashing live device control.
}
}
stop(): void {
void this.flush();
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.playSessionId = null;
if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", this.flushBeacon);
document.removeEventListener("visibilitychange", this.onVisibilityChange);
}
}
private onVisibilityChange = (): void => {
if (document.visibilityState === "hidden") this.flushBeacon();
};
private flushBeacon = (): void => {
if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return;
const events = this.drain();
navigator.sendBeacon(
`/api/play-sessions/${this.playSessionId}/events`,
new Blob([JSON.stringify({ events })], { type: "application/json" }),
);
};
private drain(): ApiEvent[] {
const events = this.buffer;
this.buffer = [];
return events
.map((e): ApiEvent | null => {
const sessionDeviceId = this.sessionDeviceIdByDeviceIndex.get(e.deviceIndex);
if (sessionDeviceId === undefined) return null;
return {
sessionDeviceId,
tsMs: e.tsMs,
commandType: e.commandType,
featureIndex: e.featureIndex,
value: e.value,
durationMs: e.durationMs,
};
})
.filter((e): e is ApiEvent => e !== null);
}
}
export const eventBuffer = new EventBuffer();
+136
View File
@@ -0,0 +1,136 @@
import { getDevice } from "./client";
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
import { eventBuffer } from "./event-buffer";
import type { ActuatorInfo, CommandEvent } from "./types";
export interface RecordingEventRow {
tsMs: number;
commandType: CommandEvent["commandType"];
featureIndex: number;
value: number;
durationMs: number | null;
sessionDeviceId: number;
}
export interface PlayerOptions {
events: RecordingEventRow[];
/** Recording's session_device_id -> currently-connected device index, from the remap step. */
sessionDeviceIdToDeviceIndex: Map<number, number>;
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
runtime: ButtplugRuntime;
speed?: number;
onProgress?: (elapsedMs: number, durationMs: number) => void;
onComplete?: () => void;
}
/**
* Replays a recording's events against the currently-connected devices,
* using performance.now()-relative scheduling (not wall-clock Date.now())
* so long sessions don't accumulate drift from setTimeout jitter.
*/
export class RecordingPlayer {
private readonly durationMs: number;
private startedAtPerf = 0;
private pausedAtMs = 0;
private timers: ReturnType<typeof setTimeout>[] = [];
private progressTimer: ReturnType<typeof setInterval> | null = null;
private playing = false;
constructor(private readonly options: PlayerOptions) {
this.durationMs = options.events.at(-1)?.tsMs ?? 0;
}
get isPlaying(): boolean {
return this.playing;
}
play(): void {
if (this.playing) return;
const speed = this.options.speed ?? 1;
this.playing = true;
this.startedAtPerf = performance.now() - this.pausedAtMs / speed;
this.scheduleFrom(this.pausedAtMs);
}
pause(): void {
if (!this.playing) return;
const speed = this.options.speed ?? 1;
this.pausedAtMs = (performance.now() - this.startedAtPerf) * speed;
this.playing = false;
this.clearTimers();
}
seek(ms: number): void {
const wasPlaying = this.playing;
this.clearTimers();
this.playing = false;
this.pausedAtMs = Math.max(0, Math.min(ms, this.durationMs));
if (wasPlaying) this.play();
}
stop(): void {
this.playing = false;
this.pausedAtMs = 0;
this.clearTimers();
}
private clearTimers(): void {
this.timers.forEach(clearTimeout);
this.timers = [];
if (this.progressTimer) clearInterval(this.progressTimer);
this.progressTimer = null;
}
private scheduleFrom(fromMs: number): void {
const speed = this.options.speed ?? 1;
for (const event of this.options.events.filter((e) => e.tsMs >= fromMs)) {
const delay = (event.tsMs - fromMs) / speed;
this.timers.push(setTimeout(() => void this.dispatch(event), delay));
}
this.timers.push(
setTimeout(() => {
this.playing = false;
this.clearTimers();
this.options.onComplete?.();
}, (this.durationMs - fromMs) / speed),
);
this.progressTimer = setInterval(() => {
if (!this.playing) return;
const elapsed = Math.min((performance.now() - this.startedAtPerf) * speed, this.durationMs);
this.options.onProgress?.(elapsed, this.durationMs);
}, 200);
}
private async dispatch(event: RecordingEventRow): Promise<void> {
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
if (deviceIndex === undefined) return;
const device = await getDevice(deviceIndex);
if (!device) return;
eventBuffer.record({
deviceIndex,
commandType: event.commandType,
featureIndex: event.featureIndex,
value: event.value,
durationMs: event.durationMs ?? undefined,
});
if (event.commandType === "stop") {
await device.stop();
return;
}
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);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { create } from "zustand";
import type { ConnectedDeviceInfo } from "./types";
export const actuatorKey = (deviceIndex: number, featureIndex: number): string =>
`${deviceIndex}:${featureIndex}`;
interface ButtplugStoreState {
connected: boolean;
scanning: boolean;
devices: Record<number, ConnectedDeviceInfo>;
/** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */
actuatorValues: Record<string, number>;
error: string | null;
setConnected: (connected: boolean) => void;
setScanning: (scanning: boolean) => void;
upsertDevice: (device: ConnectedDeviceInfo) => void;
removeDevice: (index: number) => void;
setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void;
setError: (message: string | null) => void;
reset: () => void;
}
export const useButtplugStore = create<ButtplugStoreState>((set) => ({
connected: false,
scanning: false,
devices: {},
actuatorValues: {},
error: null,
setConnected: (connected) => set({ connected }),
setScanning: (scanning) => set({ scanning }),
upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })),
removeDevice: (index) =>
set((s) => {
const devices = { ...s.devices };
delete devices[index];
return { devices };
}),
setActuatorValue: (deviceIndex, featureIndex, value) =>
set((s) => ({
actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value },
})),
setError: (message) => set({ error: message }),
reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {} }),
}));
+53
View File
@@ -0,0 +1,53 @@
/**
* Per-actuator leading+trailing debounce so a dragged slider doesn't fire a
* device command on every pointermove - devices don't need mouse-move-rate
* updates, and most BLE stacks choke on a command flood.
*/
const THROTTLE_MS = 75;
interface ThrottleEntry {
timer: ReturnType<typeof setTimeout> | null;
pendingValue: number | null;
pendingDuration: number | undefined;
lastSentAt: number;
}
const entries = new Map<string, ThrottleEntry>();
export function throttledSend(
key: string,
send: (value: number, durationMs?: number) => void | Promise<void>,
value: number,
durationMs?: number,
): void {
let entry = entries.get(key);
if (!entry) {
entry = { timer: null, pendingValue: null, pendingDuration: undefined, lastSentAt: 0 };
entries.set(key, entry);
}
entry.pendingValue = value;
entry.pendingDuration = durationMs;
const flush = (): void => {
const v = entry!.pendingValue;
entry!.pendingValue = null;
entry!.timer = null;
entry!.lastSentAt = Date.now();
if (v !== null) void send(v, entry!.pendingDuration);
};
const elapsed = Date.now() - entry.lastSentAt;
if (elapsed >= THROTTLE_MS) {
if (entry.timer) clearTimeout(entry.timer);
flush();
} else if (!entry.timer) {
entry.timer = setTimeout(flush, THROTTLE_MS - elapsed);
}
}
export function clearThrottle(key: string): void {
const entry = entries.get(key);
if (entry?.timer) clearTimeout(entry.timer);
entries.delete(key);
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Normalized, app-level types for the Buttplug integration. Kept separate
* from `buttplug`'s own types so the rest of the app (UI, API payloads, DB
* rows) never has to import the client library directly.
*/
export type NormalizedOutputType = "vibrate" | "rotate" | "linear";
export type CommandType = NormalizedOutputType | "stop";
export interface ActuatorInfo {
featureIndex: number;
outputType: NormalizedOutputType;
/** True for outputs (e.g. HwPositionWithDuration) that require a move duration. */
requiresDuration: boolean;
descriptor: string;
}
export interface ConnectedDeviceInfo {
index: number;
name: string;
displayName?: string;
actuators: ActuatorInfo[];
}
/** A single dispatched command, timestamped relative to session start. */
export interface CommandEvent {
tsMs: number;
deviceIndex: number;
commandType: CommandType;
featureIndex: number;
value: number;
durationMs?: number;
}
+43
View File
@@ -0,0 +1,43 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getEnv } from "@/lib/env";
import * as schema from "./schema";
const globalForDb = globalThis as unknown as { __bpSqlite?: Database.Database };
function openDatabase(): Database.Database {
if (globalForDb.__bpSqlite) return globalForDb.__bpSqlite;
const path = getEnv().DATABASE_PATH;
mkdirSync(dirname(path), { recursive: true });
const instance = new Database(path);
instance.pragma("journal_mode = WAL");
instance.pragma("foreign_keys = ON");
// Reused across hot-reloads in dev so we don't leak file handles / open a
// fresh WAL-mode connection on every request.
globalForDb.__bpSqlite = instance;
return instance;
}
// Lazy by design: `next build` imports this module's whole graph while
// collecting route metadata, even for force-dynamic pages that are never
// actually rendered at build time. Opening the DB (and requiring env vars)
// as a module-level side effect would make `pnpm build` fail in CI, where
// no secrets are configured. The proxy defers both until first real query.
export const sqlite = new Proxy({} as Database.Database, {
get(_target, prop) {
// drizzle-orm's `isConfig()` probes `.constructor.name` on its first
// argument to tell a client instance from a plain config object - that
// introspection must not itself trigger opening the real connection.
if (prop === "constructor") return Database;
const instance = openDatabase();
const value = Reflect.get(instance, prop, instance);
return typeof value === "function" ? value.bind(instance) : value;
},
}) as Database.Database;
export const db = drizzle(sqlite, { schema });
+10
View File
@@ -0,0 +1,10 @@
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client";
import { createLogger } from "@/lib/logger";
const log = createLogger("db:migrate");
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./drizzle" });
log.info("migrations applied");
}
+52
View File
@@ -0,0 +1,52 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { devices, type DeviceCapabilities } from "@/lib/db/schema";
export async function listDevices() {
return db.select().from(devices).orderBy(devices.lastConnectedAt);
}
export async function getDevice(id: number) {
const [row] = await db.select().from(devices).where(eq(devices.id, id));
return row;
}
/** Upserts by advertised BLE name - the only stable-ish identity Web Bluetooth exposes. */
export async function upsertDeviceByBleName(input: {
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}): Promise<typeof devices.$inferSelect> {
const now = Date.now();
const [existing] = await db.select().from(devices).where(eq(devices.bleName, input.bleName));
if (existing) {
const [updated] = await db
.update(devices)
.set({
lastConnectedAt: now,
deviceClass: input.deviceClass ?? existing.deviceClass,
capabilities: input.capabilities ?? existing.capabilities,
})
.where(eq(devices.id, existing.id))
.returning();
return updated;
}
const [created] = await db
.insert(devices)
.values({
bleName: input.bleName,
deviceClass: input.deviceClass,
capabilities: input.capabilities,
createdAt: now,
lastConnectedAt: now,
})
.returning();
return created;
}
export async function renameDevice(id: number, displayName: string) {
const [updated] = await db.update(devices).set({ displayName }).where(eq(devices.id, id)).returning();
return updated;
}
+112
View File
@@ -0,0 +1,112 @@
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
@@ -0,0 +1,86 @@
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));
}
+42
View File
@@ -0,0 +1,42 @@
import { asc, eq } from "drizzle-orm";
import { db, sqlite } from "@/lib/db/client";
import { sessionEvents } from "@/lib/db/schema";
export interface IncomingEvent {
sessionDeviceId: number;
tsMs: number;
commandType: "vibrate" | "rotate" | "linear" | "stop";
featureIndex: number;
value: number;
durationMs?: number;
}
export async function insertEvents(playSessionId: number, events: IncomingEvent[]): Promise<void> {
if (events.length === 0) return;
const insertMany = sqlite.transaction((rows: IncomingEvent[]) => {
for (const row of rows) {
db.insert(sessionEvents)
.values({
playSessionId,
sessionDeviceId: row.sessionDeviceId,
tsMs: row.tsMs,
commandType: row.commandType,
featureIndex: row.featureIndex,
value: row.value,
durationMs: row.durationMs,
})
.run();
}
});
insertMany(events);
}
export async function getEventsForSession(playSessionId: number) {
return db
.select()
.from(sessionEvents)
.where(eq(sessionEvents.playSessionId, playSessionId))
.orderBy(asc(sessionEvents.tsMs));
}

Some files were not shown because too many files have changed in this diff Show More