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

Next.js app with a browser-side buttplug/buttplug-wasm control layer
(server never touches real-time device commands), SQLite storage via
Drizzle, single-secret auth, recordings/replay with device remapping,
a usage stats dashboard, Docker deployment, and Gitea CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
2026-08-25 07:51:38 +02:00
co-authored by Claude Sonnet 5
commit 1119c8eea0
112 changed files with 15522 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getEnv } from "@/lib/env";
import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare";
import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session";
const bodySchema = z.object({ secret: z.string().min(1) });
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "secret is required" }, { status: 400 });
}
if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) {
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
}
const token = await createSessionToken();
const res = NextResponse.json({ ok: true });
res.cookies.set({ ...sessionCookieOptions, value: token });
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete(SESSION_COOKIE_NAME);
return res;
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { renameDevice } from "@/lib/db/queries/devices";
const bodySchema = z.object({ displayName: z.string().min(1).max(120) });
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "displayName is required" }, { status: 400 });
}
const updated = await renameDevice(Number(id), parsed.data.displayName);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ device: updated });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listDevices } from "@/lib/db/queries/devices";
export async function GET() {
const rows = await listDevices();
return NextResponse.json({ devices: rows });
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { sqlite } from "@/lib/db/client";
export async function GET() {
try {
sqlite.prepare("select 1").get();
return NextResponse.json({ status: "ok" });
} catch {
return NextResponse.json({ status: "error" }, { status: 500 });
}
}
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { insertEvents } from "@/lib/db/queries/session-events";
const eventSchema = z.object({
sessionDeviceId: z.number().int().positive(),
tsMs: z.number().int().min(0),
commandType: z.enum(["vibrate", "rotate", "linear", "stop"]),
featureIndex: z.number().int().min(0),
value: z.number().min(0).max(1),
durationMs: z.number().int().positive().optional(),
});
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
await insertEvents(Number(id), parsed.data.events);
return NextResponse.json({ inserted: parsed.data.events.length });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const detail = await getPlaySessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 });
}
const updated = await endPlaySession(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ playSession: updated });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
await deletePlaySession(Number(id));
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
return NextResponse.json(
{ error: "cannot delete a session that a saved recording still references" },
{ status: 409 },
);
}
throw err;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions";
const deviceInputSchema = z.object({
slotLabel: z.string().min(1),
bleName: z.string().min(1),
deviceClass: z.string().nullish(),
capabilities: z.object({ outputs: z.array(z.string()), featureCount: z.number() }).optional(),
});
const bodySchema = z.object({
kind: z.enum(["live", "replay"]),
replayedRecordingId: z.number().int().positive().optional(),
name: z.string().min(1).optional(),
devices: z.array(deviceInputSchema).min(1),
});
export async function GET() {
const rows = await listPlaySessions();
return NextResponse.json({ playSessions: rows });
}
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const result = await startPlaySession(parsed.data);
return NextResponse.json(result, { status: 201 });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
const patchSchema = z.object({
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
});
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(result);
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
const updated = await renameRecording(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ recording: updated });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteRecording(Number(id));
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
const bodySchema = z.object({
sourcePlaySessionId: z.number().int().positive(),
name: z.string().min(1).max(160),
description: z.string().max(2000).optional(),
});
export async function GET() {
const rows = await listRecordings();
return NextResponse.json({ recordings: rows });
}
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const recording = await createRecording(parsed.data);
return NextResponse.json({ recording }, { status: 201 });
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats";
export async function GET() {
const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 }));
return NextResponse.json({ devices });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
export async function GET() {
const stats = await getRecordingLibraryStats();
return NextResponse.json(stats);
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getSessionTimeline } from "@/lib/db/queries/stats";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000);
const timeline = await getSessionTimeline(Number(id), bucketMs);
return NextResponse.json({ timeline });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionsSummary } from "@/lib/db/queries/stats";
export async function GET() {
const summary = await getSessionsSummary();
return NextResponse.json(summary);
}