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
+16
View File
@@ -0,0 +1,16 @@
import { ButtplugConsoleLoader as ButtplugConsole } from "@/components/control/ButtplugConsoleLoader";
export default function ControlPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Control</h1>
<p className="text-sm text-muted-foreground">
Scan for devices, connect, and control them live. Every command runs directly from your browser
to the device over Web Bluetooth - the server never sees it in real time.
</p>
</div>
<ButtplugConsole />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DevicesTable } from "@/components/devices/DevicesTable";
import { listDevices } from "@/lib/db/queries/devices";
export const dynamic = "force-dynamic";
export default async function DevicesPage() {
const devices = await listDevices();
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Devices</h1>
<p className="text-sm text-muted-foreground">Devices seen across past sessions. Give them friendlier names.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Known devices</CardTitle>
</CardHeader>
<CardContent>
<DevicesTable devices={devices} />
</CardContent>
</Card>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { NavBar } from "@/components/layout/NavBar";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-dvh">
<NavBar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { listRecordings } from "@/lib/db/queries/recordings";
import { listPlaySessions } from "@/lib/db/queries/play-sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats";
// Always reflects live DB state for an authenticated, single-tenant app -
// never worth prerendering (and the DB file doesn't exist at build time).
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [recordings, sessions, summary] = await Promise.all([
listRecordings(),
listPlaySessions(),
getSessionsSummary(),
]);
const recentRecordings = recordings.slice(0, 5);
const recentSessions = [...sessions].reverse().slice(0, 5);
return (
<div className="flex flex-col gap-8">
<Card className="bp-glass overflow-hidden">
<CardContent className="flex flex-col items-start gap-4 py-8">
<h1 className="font-display bp-gradient-text text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground">
Scan for nearby devices, take control, and record sessions to replay later - all running
directly from your browser over Web Bluetooth.
</p>
<Button asChild size="lg">
<Link href="/control">Start a session</Link>
</Button>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle>
</CardHeader>
<CardContent className="font-display text-3xl">{recordings.length}</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent recordings</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{recentRecordings.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing saved yet.</p>
)}
{recentRecordings.map((r) => (
<Link
key={r.id}
href={`/recordings/${r.id}`}
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted"
>
<span>{r.name}</span>
<span className="text-muted-foreground">{r.playCount} plays</span>
</Link>
))}
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent sessions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentSessions.map((s) => (
<Link
key={s.id}
href={`/sessions/${s.id}`}
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted"
>
<span>{s.name ?? `Session #${s.id}`}</span>
<span className="text-muted-foreground">{new Date(s.startedAt).toLocaleDateString()}</span>
</Link>
))}
</CardContent>
</Card>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getRecording } from "@/lib/db/queries/recordings";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic";
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export default async function RecordingDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) notFound();
const { recording } = result;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">{recording.name}</h1>
{recording.description && <p className="text-sm text-muted-foreground">{recording.description}</p>}
</div>
<Button asChild>
<Link href={`/recordings/${recording.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Details</CardTitle>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">Duration: </span>
{formatDuration(recording.durationMs)}
</div>
<div>
<span className="text-muted-foreground">Plays: </span>
{recording.playCount}
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(recording.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Last played: </span>
{recording.lastPlayedAt ? new Date(recording.lastPlayedAt).toLocaleString() : "Never"}
</div>
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Devices in this recording</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{recording.deviceSlots.map((slot) => (
<div key={slot.sourceSessionDeviceId} className="text-sm">
{slot.slotLabel} <span className="text-muted-foreground">({slot.recordedBleName})</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader";
export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<div className="flex flex-col gap-6">
<h1 className="font-display text-2xl font-semibold">Replay</h1>
<ReplayPlayerLoader recordingId={Number(id)} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecordingsTable } from "@/components/recordings/RecordingsTable";
import { listRecordings } from "@/lib/db/queries/recordings";
export const dynamic = "force-dynamic";
export default async function RecordingsPage() {
const recordings = await listRecordings();
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Recordings</h1>
<p className="text-sm text-muted-foreground">Saved sessions you can replay against connected devices.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Library</CardTitle>
</CardHeader>
<CardContent>
<RecordingsTable recordings={recordings} />
</CardContent>
</Card>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic";
function formatDuration(ms: number | null): string {
if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const sessionId = Number(id);
const [detail, timeline] = await Promise.all([getPlaySessionDetail(sessionId), getSessionTimeline(sessionId)]);
if (!detail) notFound();
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
<p className="text-sm text-muted-foreground">
{detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)}
</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Intensity timeline</CardTitle>
</CardHeader>
<CardContent>
<SessionTimelineChart timeline={timeline} />
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Devices</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{detail.devices.map((d) => (
<div key={d.id} className="text-sm">
{d.slotLabel} <span className="text-muted-foreground">({d.deviceDisplayName ?? d.deviceBleName})</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionsTable } from "@/components/sessions/SessionsTable";
import { listPlaySessions } from "@/lib/db/queries/play-sessions";
export const dynamic = "force-dynamic";
export default async function SessionsPage() {
const sessions = [...(await listPlaySessions())].reverse();
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Sessions</h1>
<p className="text-sm text-muted-foreground">History of live control and replay sessions.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
</CardHeader>
<CardContent>
<SessionsTable sessions={sessions} />
</CardContent>
</Card>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats";
import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic";
export default async function StatsPage() {
const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([
getSessionsSummary(),
getDeviceUsageStats(),
getDeviceCommandCounts(),
getRecordingLibraryStats(),
]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
const devices = deviceUsage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 }));
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold">Stats</h1>
<p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p>
</div>
<Tabs defaultValue="sessions">
<TabsList>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger>
<TabsTrigger value="recordings">Recordings</TabsTrigger>
</TabsList>
<TabsContent value="sessions" className="pt-4">
<SessionsSummaryCards summary={sessionsSummary} />
</TabsContent>
<TabsContent value="devices" className="pt-4">
<DeviceUsageTable devices={devices} />
</TabsContent>
<TabsContent value="recordings" className="pt-4">
<RecordingLibraryStats stats={recordingStats} />
</TabsContent>
</Tabs>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getEnv } from "@/lib/env";
import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare";
import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session";
const bodySchema = z.object({ secret: z.string().min(1) });
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "secret is required" }, { status: 400 });
}
if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) {
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
}
const token = await createSessionToken();
const res = NextResponse.json({ ok: true });
res.cookies.set({ ...sessionCookieOptions, value: token });
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete(SESSION_COOKIE_NAME);
return res;
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { renameDevice } from "@/lib/db/queries/devices";
const bodySchema = z.object({ displayName: z.string().min(1).max(120) });
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "displayName is required" }, { status: 400 });
}
const updated = await renameDevice(Number(id), parsed.data.displayName);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ device: updated });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listDevices } from "@/lib/db/queries/devices";
export async function GET() {
const rows = await listDevices();
return NextResponse.json({ devices: rows });
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { sqlite } from "@/lib/db/client";
export async function GET() {
try {
sqlite.prepare("select 1").get();
return NextResponse.json({ status: "ok" });
} catch {
return NextResponse.json({ status: "error" }, { status: 500 });
}
}
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { insertEvents } from "@/lib/db/queries/session-events";
const eventSchema = z.object({
sessionDeviceId: z.number().int().positive(),
tsMs: z.number().int().min(0),
commandType: z.enum(["vibrate", "rotate", "linear", "stop"]),
featureIndex: z.number().int().min(0),
value: z.number().min(0).max(1),
durationMs: z.number().int().positive().optional(),
});
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
await insertEvents(Number(id), parsed.data.events);
return NextResponse.json({ inserted: parsed.data.events.length });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const detail = await getPlaySessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 });
}
const updated = await endPlaySession(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ playSession: updated });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
await deletePlaySession(Number(id));
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
return NextResponse.json(
{ error: "cannot delete a session that a saved recording still references" },
{ status: 409 },
);
}
throw err;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions";
const deviceInputSchema = z.object({
slotLabel: z.string().min(1),
bleName: z.string().min(1),
deviceClass: z.string().nullish(),
capabilities: z.object({ outputs: z.array(z.string()), featureCount: z.number() }).optional(),
});
const bodySchema = z.object({
kind: z.enum(["live", "replay"]),
replayedRecordingId: z.number().int().positive().optional(),
name: z.string().min(1).optional(),
devices: z.array(deviceInputSchema).min(1),
});
export async function GET() {
const rows = await listPlaySessions();
return NextResponse.json({ playSessions: rows });
}
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const result = await startPlaySession(parsed.data);
return NextResponse.json(result, { status: 201 });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
const patchSchema = z.object({
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
});
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(result);
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
const updated = await renameRecording(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ recording: updated });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteRecording(Number(id));
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
const bodySchema = z.object({
sourcePlaySessionId: z.number().int().positive(),
name: z.string().min(1).max(160),
description: z.string().max(2000).optional(),
});
export async function GET() {
const rows = await listRecordings();
return NextResponse.json({ recordings: rows });
}
export async function POST(req: Request) {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const recording = await createRecording(parsed.data);
return NextResponse.json({ recording }, { status: 201 });
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats";
export async function GET() {
const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 }));
return NextResponse.json({ devices });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
export async function GET() {
const stats = await getRecordingLibraryStats();
return NextResponse.json(stats);
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getSessionTimeline } from "@/lib/db/queries/stats";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000);
const timeline = await getSessionTimeline(Number(id), bucketMs);
return NextResponse.json({ timeline });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionsSummary } from "@/lib/db/queries/stats";
export async function GET() {
const summary = await getSessionsSummary();
return NextResponse.json(summary);
}
+194
View File
@@ -0,0 +1,194 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-display);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/*
* Palette: dark-mode-first (fits the product's low-lit, intimate register).
* Every hue anchors to the pink -> purple -> blue brand sweep; --primary/
* --accent/--chart-* are the only places it should read as loud. Card
* surfaces stay a near-neutral indigo-black so the sweep doesn't flood
* every panel.
*/
:root {
--background: oklch(0.99 0.005 300);
--foreground: oklch(0.22 0.03 290);
--card: oklch(0.98 0.008 300);
--card-foreground: oklch(0.22 0.03 290);
--popover: oklch(0.99 0.005 300);
--popover-foreground: oklch(0.22 0.03 290);
--primary: oklch(0.58 0.23 322);
--primary-foreground: oklch(0.99 0.01 322);
--secondary: oklch(0.94 0.02 300);
--secondary-foreground: oklch(0.3 0.05 300);
--muted: oklch(0.95 0.015 300);
--muted-foreground: oklch(0.5 0.03 290);
--accent: oklch(0.9 0.06 330);
--accent-foreground: oklch(0.3 0.1 330);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.9 0.02 300);
--input: oklch(0.9 0.02 300);
--ring: oklch(0.58 0.23 322);
--chart-1: oklch(0.72 0.2 350);
--chart-2: oklch(0.62 0.24 320);
--chart-3: oklch(0.58 0.22 280);
--chart-4: oklch(0.6 0.2 250);
--chart-5: oklch(0.68 0.15 220);
--radius: 0.9rem;
--sidebar: oklch(0.97 0.01 300);
--sidebar-foreground: oklch(0.22 0.03 290);
--sidebar-primary: oklch(0.58 0.23 322);
--sidebar-primary-foreground: oklch(0.99 0.01 322);
--sidebar-accent: oklch(0.9 0.06 330);
--sidebar-accent-foreground: oklch(0.3 0.1 330);
--sidebar-border: oklch(0.9 0.02 300);
--sidebar-ring: oklch(0.58 0.23 322);
}
.dark {
--background: oklch(0.16 0.025 285);
--foreground: oklch(0.95 0.015 290);
--card: oklch(0.21 0.03 285);
--card-foreground: oklch(0.95 0.015 290);
--popover: oklch(0.19 0.03 285);
--popover-foreground: oklch(0.95 0.015 290);
--primary: oklch(0.7 0.2 322);
--primary-foreground: oklch(0.15 0.03 322);
--secondary: oklch(0.28 0.04 285);
--secondary-foreground: oklch(0.92 0.02 290);
--muted: oklch(0.26 0.03 285);
--muted-foreground: oklch(0.68 0.03 290);
--accent: oklch(0.35 0.09 330);
--accent-foreground: oklch(0.95 0.03 330);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.7 0.2 322);
--chart-1: oklch(0.75 0.19 350);
--chart-2: oklch(0.7 0.22 320);
--chart-3: oklch(0.68 0.2 280);
--chart-4: oklch(0.68 0.19 250);
--chart-5: oklch(0.72 0.15 220);
--sidebar: oklch(0.19 0.03 285);
--sidebar-foreground: oklch(0.95 0.015 290);
--sidebar-primary: oklch(0.7 0.2 322);
--sidebar-primary-foreground: oklch(0.15 0.03 322);
--sidebar-accent: oklch(0.35 0.09 330);
--sidebar-accent-foreground: oklch(0.95 0.03 330);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.7 0.2 322);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* Signature pink -> purple -> blue sweep, used sparingly (primary CTAs,
active rings, headline accents) rather than flooding every surface. */
.bp-gradient-text {
background: linear-gradient(
100deg,
oklch(0.75 0.19 350),
oklch(0.65 0.23 322),
oklch(0.62 0.2 260)
);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.bp-gradient-ring {
background: linear-gradient(
135deg,
oklch(0.75 0.19 350),
oklch(0.65 0.23 322),
oklch(0.62 0.2 260)
);
}
/* Glassy translucent card surface, subtle glow on hover. */
.bp-glass {
background: color-mix(in oklch, var(--card) 72%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
}
.bp-glass:hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--border));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 25%, transparent), 0 10px 30px -12px rgba(0, 0, 0, 0.45);
}
@keyframes bp-pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 55%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent);
}
}
.bp-pulse {
animation: bp-pulse-glow 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bp-pulse {
animation: none;
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import "./globals.css";
import { Geist, Space_Grotesk } from "next/font/google";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Toaster } from "@/components/ui/sonner";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" });
export const metadata: Metadata = {
title: "sexy",
description: "Bluetooth toy control console",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
suppressHydrationWarning
className={cn("font-sans", geist.variable, spaceGrotesk.variable)}
>
<body>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Suspense } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "@/components/auth/LoginForm";
export default function LoginPage() {
return (
<div className="flex min-h-dvh items-center justify-center p-4">
<Card className="bp-glass w-full max-w-sm">
<CardHeader>
<CardTitle className="font-display bp-gradient-text text-2xl">sexy</CardTitle>
<CardDescription>Enter the shared access secret to continue.</CardDescription>
</CardHeader>
<CardContent>
<Suspense>
<LoginForm />
</Suspense>
</CardContent>
</Card>
</div>
);
}