Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.log
|
||||
.DS_Store
|
||||
.env*
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 -->
|
||||
+54
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't support Web Bluetooth, so device control isn't available here. Use a
|
||||
Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can't run
|
||||
Web Bluetooth at all, on any browser - this is an Apple platform limitation, not a bug.
|
||||
</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'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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't expose a stable device id across sessions, so match each recorded
|
||||
device to a currently-connected one. Two identically-named devices can't be told apart
|
||||
automatically.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1787617808883,
|
||||
"tag": "0000_bouncy_anita_blake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {} }),
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user