Restyle UI with a channel-strip console look and brand icon
CI / Static checks (push) Successful in 1m9s
CI / Build and push image (push) Failing after 1m2s

Adds a pink/purple/blue SVG brand mark (favicon + header) and reworks
the visual language around the app's actual interaction - fader-driven
device control - via LED-style indicators, mono instrument readouts,
hairline-framed cards, console-tab nav, and an oscilloscope-style
session timeline. Leaves components/ui/* untouched, restyling only via
design tokens and the app's own composed components. Also fixes a
proxy.ts matcher gap that was gating /icon.svg behind auth.

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 16:43:28 +02:00
co-authored by Claude Sonnet 5
parent 1119c8eea0
commit f7503d03d0
27 changed files with 278 additions and 183 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# sexy # SEXY
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the 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 [Buttplug](https://buttplug.io) protocol - the same open-source protocol behind Intiface Central and most
+1 -1
View File
@@ -4,7 +4,7 @@ export default function ControlPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Control</h1> <h1 className="font-heading text-2xl font-semibold">Control</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Scan for devices, connect, and control them live. Every command runs directly from your browser 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. to the device over Web Bluetooth - the server never sees it in real time.
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function DevicesPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Devices</h1> <h1 className="font-heading text-2xl font-semibold">Devices</h1>
<p className="text-sm text-muted-foreground">Devices seen across past sessions. Give them friendlier names.</p> <p className="text-sm text-muted-foreground">Devices seen across past sessions. Give them friendlier names.</p>
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
+24 -22
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark";
import { listRecordings } from "@/lib/db/queries/recordings"; import { listRecordings } from "@/lib/db/queries/recordings";
import { listPlaySessions } from "@/lib/db/queries/play-sessions"; import { listPlaySessions } from "@/lib/db/queries/play-sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats"; import { getSessionsSummary } from "@/lib/db/queries/stats";
@@ -23,7 +24,8 @@ export default async function DashboardPage() {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Card className="bp-glass overflow-hidden"> <Card className="bp-glass overflow-hidden">
<CardContent className="flex flex-col items-start gap-4 py-8"> <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> <BrandMark size={40} className="bp-pulse rounded-[9px]" />
<h1 className="font-heading bp-gradient-text text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground"> <p className="max-w-xl text-sm text-muted-foreground">
Scan for nearby devices, take control, and record sessions to replay later - all running Scan for nearby devices, take control, and record sessions to replay later - all running
directly from your browser over Web Bluetooth. directly from your browser over Web Bluetooth.
@@ -39,13 +41,13 @@ export default async function DashboardPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle> <CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{summary.count}</CardContent> <CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle> <CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl"> <CardContent className="bp-readout text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h {(summary.totalDurationMs / 3_600_000).toFixed(1)}h
</CardContent> </CardContent>
</Card> </Card>
@@ -53,7 +55,7 @@ export default async function DashboardPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle> <CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{recordings.length}</CardContent> <CardContent className="bp-readout text-3xl">{recordings.length}</CardContent>
</Card> </Card>
</div> </div>
@@ -62,18 +64,17 @@ export default async function DashboardPage() {
<CardHeader> <CardHeader>
<CardTitle>Recent recordings</CardTitle> <CardTitle>Recent recordings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col">
{recentRecordings.length === 0 && ( {recentRecordings.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing saved yet.</p> <p className="text-sm text-muted-foreground">Nothing saved yet.</p>
)} )}
{recentRecordings.map((r) => ( {recentRecordings.map((r, i) => (
<Link <Link key={r.id} href={`/recordings/${r.id}`} className="group">
key={r.id} {i > 0 && <div className="bp-hairline" />}
href={`/recordings/${r.id}`} <div className="flex items-center justify-between px-1 py-2 text-sm">
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted" <span className="group-hover:text-primary">{r.name}</span>
> <span className="bp-readout text-xs text-muted-foreground">{r.playCount} plays</span>
<span>{r.name}</span> </div>
<span className="text-muted-foreground">{r.playCount} plays</span>
</Link> </Link>
))} ))}
</CardContent> </CardContent>
@@ -83,16 +84,17 @@ export default async function DashboardPage() {
<CardHeader> <CardHeader>
<CardTitle>Recent sessions</CardTitle> <CardTitle>Recent sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>} {recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentSessions.map((s) => ( {recentSessions.map((s, i) => (
<Link <Link key={s.id} href={`/sessions/${s.id}`} className="group">
key={s.id} {i > 0 && <div className="bp-hairline" />}
href={`/sessions/${s.id}`} <div className="flex items-center justify-between px-1 py-2 text-sm">
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-muted" <span className="group-hover:text-primary">{s.name ?? `Session #${s.id}`}</span>
> <span className="bp-readout text-xs text-muted-foreground">
<span>{s.name ?? `Session #${s.id}`}</span> {new Date(s.startedAt).toLocaleDateString()}
<span className="text-muted-foreground">{new Date(s.startedAt).toLocaleDateString()}</span> </span>
</div>
</Link> </Link>
))} ))}
</CardContent> </CardContent>
+1 -1
View File
@@ -23,7 +23,7 @@ export default async function RecordingDetailPage({ params }: { params: Promise<
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="font-display text-2xl font-semibold">{recording.name}</h1> <h1 className="font-heading text-2xl font-semibold">{recording.name}</h1>
{recording.description && <p className="text-sm text-muted-foreground">{recording.description}</p>} {recording.description && <p className="text-sm text-muted-foreground">{recording.description}</p>}
</div> </div>
<Button asChild> <Button asChild>
+1 -1
View File
@@ -4,7 +4,7 @@ export default async function ReplayPage({ params }: { params: Promise<{ id: str
const { id } = await params; const { id } = await params;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="font-display text-2xl font-semibold">Replay</h1> <h1 className="font-heading text-2xl font-semibold">Replay</h1>
<ReplayPlayerLoader recordingId={Number(id)} /> <ReplayPlayerLoader recordingId={Number(id)} />
</div> </div>
); );
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function RecordingsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Recordings</h1> <h1 className="font-heading text-2xl font-semibold">Recordings</h1>
<p className="text-sm text-muted-foreground">Saved sessions you can replay against connected devices.</p> <p className="text-sm text-muted-foreground">Saved sessions you can replay against connected devices.</p>
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1> <h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)} {detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)}
</p> </p>
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function SessionsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Sessions</h1> <h1 className="font-heading text-2xl font-semibold">Sessions</h1>
<p className="text-sm text-muted-foreground">History of live control and replay sessions.</p> <p className="text-sm text-muted-foreground">History of live control and replay sessions.</p>
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function StatsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Stats</h1> <h1 className="font-heading text-2xl font-semibold">Stats</h1>
<p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p> <p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p>
</div> </div>
+108 -92
View File
@@ -7,6 +7,7 @@
@theme inline { @theme inline {
--font-heading: var(--font-display); --font-heading: var(--font-display);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,79 +49,77 @@
} }
/* /*
* Palette: dark-mode-first (fits the product's low-lit, intimate register). * Modern pink -> purple -> blue palette (the app's signature sweep, also
* Every hue anchors to the pink -> purple -> blue brand sweep; --primary/ * used in app/icon.svg / BrandMark). Dark cool near-black base with a vivid
* --accent/--chart-* are the only places it should read as loud. Card * pink primary; violet/blue fill out charts and the wordmark/icon gradient.
* surfaces stay a near-neutral indigo-black so the sweep doesn't flood
* every panel.
*/ */
:root { :root {
--background: oklch(0.99 0.005 300); --background: #f5f3fb;
--foreground: oklch(0.22 0.03 290); --foreground: #1f1b2e;
--card: oklch(0.98 0.008 300); --card: #ffffff;
--card-foreground: oklch(0.22 0.03 290); --card-foreground: #1f1b2e;
--popover: oklch(0.99 0.005 300); --popover: #ffffff;
--popover-foreground: oklch(0.22 0.03 290); --popover-foreground: #1f1b2e;
--primary: oklch(0.58 0.23 322); --primary: #db2777;
--primary-foreground: oklch(0.99 0.01 322); --primary-foreground: #ffffff;
--secondary: oklch(0.94 0.02 300); --secondary: #ede9fe;
--secondary-foreground: oklch(0.3 0.05 300); --secondary-foreground: #3a2d54;
--muted: oklch(0.95 0.015 300); --muted: #efeafd;
--muted-foreground: oklch(0.5 0.03 290); --muted-foreground: #6b6280;
--accent: oklch(0.9 0.06 330); --accent: #ede4fb;
--accent-foreground: oklch(0.3 0.1 330); --accent-foreground: #4c1d95;
--destructive: oklch(0.577 0.245 27.325); --destructive: #dc2626;
--border: oklch(0.9 0.02 300); --border: #e4defa;
--input: oklch(0.9 0.02 300); --input: #e4defa;
--ring: oklch(0.58 0.23 322); --ring: #db2777;
--chart-1: oklch(0.72 0.2 350); --chart-1: #db2777;
--chart-2: oklch(0.62 0.24 320); --chart-2: #9333ea;
--chart-3: oklch(0.58 0.22 280); --chart-3: #7c3aed;
--chart-4: oklch(0.6 0.2 250); --chart-4: #2563eb;
--chart-5: oklch(0.68 0.15 220); --chart-5: #0284c7;
--radius: 0.9rem; --radius: 0.75rem;
--sidebar: oklch(0.97 0.01 300); --sidebar: #ede9fe;
--sidebar-foreground: oklch(0.22 0.03 290); --sidebar-foreground: #1f1b2e;
--sidebar-primary: oklch(0.58 0.23 322); --sidebar-primary: #db2777;
--sidebar-primary-foreground: oklch(0.99 0.01 322); --sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.9 0.06 330); --sidebar-accent: #ede4fb;
--sidebar-accent-foreground: oklch(0.3 0.1 330); --sidebar-accent-foreground: #4c1d95;
--sidebar-border: oklch(0.9 0.02 300); --sidebar-border: #e4defa;
--sidebar-ring: oklch(0.58 0.23 322); --sidebar-ring: #db2777;
} }
.dark { .dark {
--background: oklch(0.16 0.025 285); --background: #15131f;
--foreground: oklch(0.95 0.015 290); --foreground: #f2f0f7;
--card: oklch(0.21 0.03 285); --card: #1f1b2c;
--card-foreground: oklch(0.95 0.015 290); --card-foreground: #f2f0f7;
--popover: oklch(0.19 0.03 285); --popover: #1c1829;
--popover-foreground: oklch(0.95 0.015 290); --popover-foreground: #f2f0f7;
--primary: oklch(0.7 0.2 322); --primary: #ec4899;
--primary-foreground: oklch(0.15 0.03 322); --primary-foreground: #ffffff;
--secondary: oklch(0.28 0.04 285); --secondary: #2a2440;
--secondary-foreground: oklch(0.92 0.02 290); --secondary-foreground: #e5e1f0;
--muted: oklch(0.26 0.03 285); --muted: #272138;
--muted-foreground: oklch(0.68 0.03 290); --muted-foreground: #a79fc0;
--accent: oklch(0.35 0.09 330); --accent: #3a2d54;
--accent-foreground: oklch(0.95 0.03 330); --accent-foreground: #efeaff;
--destructive: oklch(0.704 0.191 22.216); --destructive: #ef4444;
--border: oklch(1 0 0 / 10%); --border: color-mix(in srgb, #a78bfa 25%, transparent);
--input: oklch(1 0 0 / 14%); --input: color-mix(in srgb, #a78bfa 18%, transparent);
--ring: oklch(0.7 0.2 322); --ring: #ec4899;
--chart-1: oklch(0.75 0.19 350); --chart-1: #f472b6;
--chart-2: oklch(0.7 0.22 320); --chart-2: #c084fc;
--chart-3: oklch(0.68 0.2 280); --chart-3: #a78bfa;
--chart-4: oklch(0.68 0.19 250); --chart-4: #60a5fa;
--chart-5: oklch(0.72 0.15 220); --chart-5: #38bdf8;
--sidebar: oklch(0.19 0.03 285); --sidebar: #1c1829;
--sidebar-foreground: oklch(0.95 0.015 290); --sidebar-foreground: #f2f0f7;
--sidebar-primary: oklch(0.7 0.2 322); --sidebar-primary: #ec4899;
--sidebar-primary-foreground: oklch(0.15 0.03 322); --sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.35 0.09 330); --sidebar-accent: #3a2d54;
--sidebar-accent-foreground: oklch(0.95 0.03 330); --sidebar-accent-foreground: #efeaff;
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: color-mix(in srgb, #a78bfa 25%, transparent);
--sidebar-ring: oklch(0.7 0.2 322); --sidebar-ring: #ec4899;
} }
@layer base { @layer base {
@@ -135,52 +134,69 @@
} }
} }
/* Signature pink -> purple -> blue sweep, used sparingly (primary CTAs, /* Signature pink -> purple -> blue sweep (matches app/icon.svg), used for
active rings, headline accents) rather than flooding every surface. */ the wordmark and one hero moment - not smeared across every surface. */
.bp-gradient-text { .bp-gradient-text {
background: linear-gradient( background: linear-gradient(100deg, #ff6fb0, #b34bde 50%, #4f7fe0);
100deg,
oklch(0.75 0.19 350),
oklch(0.65 0.23 322),
oklch(0.62 0.2 260)
);
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
color: transparent; color: transparent;
} }
.bp-gradient-ring { .bp-gradient-ring {
background: linear-gradient( background: linear-gradient(135deg, #ff6fb0, #b34bde 50%, #4f7fe0);
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. */ /* Console-module surface: hairline border + a soft inset top highlight,
standing in for backdrop-blur glassmorphism. */
.bp-glass { .bp-glass {
background: color-mix(in oklch, var(--card) 72%, transparent); background: var(--card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border); border: 1px solid var(--border);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent);
transition: transition:
box-shadow 0.2s ease,
border-color 0.2s ease, border-color 0.2s ease,
transform 0.2s ease; box-shadow 0.2s ease;
} }
.bp-glass:hover { .bp-glass:hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--border)); border-color: color-mix(in srgb, 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); box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--foreground) 10%, transparent),
0 10px 30px -14px rgba(0, 0, 0, 0.45);
}
/* Illuminated indicator dot - reused on connection status and per-actuator
transmit indicators. */
.bp-led {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--muted-foreground);
}
.bp-led[data-on="true"] {
background: var(--primary);
}
/* Thin panel seam - a hardware "trim line," not a plain UI divider. */
.bp-hairline {
height: 1px;
background: color-mix(in srgb, #a78bfa 35%, transparent);
}
/* Instrument-readout numerals: tabular mono for actuator %, timers, stats. */
.bp-readout {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
} }
@keyframes bp-pulse-glow { @keyframes bp-pulse-glow {
0%, 0%,
100% { 100% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 55%, transparent); box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent);
} }
50% { 50% {
box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent);
} }
} }
.bp-pulse { .bp-pulse {
+22
View File
@@ -0,0 +1,22 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bpGrad" x1="2" y1="2" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ff6fb0" />
<stop offset="52%" stop-color="#b34bde" />
<stop offset="100%" stop-color="#4f7fe0" />
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGrad)" />
<path
d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z"
fill="rgba(255,255,255,0.22)"
/>
<polyline
points="6,15 10,15 12,9 15,21 18,13 20,15 26,15"
fill="none"
stroke="#ffffff"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 884 B

+5 -4
View File
@@ -1,15 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Geist, Space_Grotesk } from "next/font/google"; import { Inter, Space_Grotesk, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/layout/ThemeProvider"; import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" }); const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "sexy", title: "SEXY",
description: "Bluetooth toy control console", description: "Bluetooth toy control console",
}; };
@@ -18,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html <html
lang="en" lang="en"
suppressHydrationWarning suppressHydrationWarning
className={cn("font-sans", geist.variable, spaceGrotesk.variable)} className={cn("font-sans", inter.variable, spaceGrotesk.variable, jetbrainsMono.variable)}
> >
<body> <body>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
+5 -1
View File
@@ -1,13 +1,17 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "@/components/auth/LoginForm"; import { LoginForm } from "@/components/auth/LoginForm";
import { BrandMark } from "@/components/layout/BrandMark";
export default function LoginPage() { export default function LoginPage() {
return ( return (
<div className="flex min-h-dvh items-center justify-center p-4"> <div className="flex min-h-dvh items-center justify-center p-4">
<Card className="bp-glass w-full max-w-sm"> <Card className="bp-glass w-full max-w-sm">
<CardHeader> <CardHeader>
<CardTitle className="font-display bp-gradient-text text-2xl">sexy</CardTitle> <div className="flex items-center gap-2">
<BrandMark size={32} />
<CardTitle className="font-heading bp-gradient-text text-2xl">SEXY</CardTitle>
</div>
<CardDescription>Enter the shared access secret to continue.</CardDescription> <CardDescription>Enter the shared access secret to continue.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+7 -10
View File
@@ -12,20 +12,17 @@ interface ActuatorSliderProps {
export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) { export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) {
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2 rounded-md border border-border/70 bg-background/40 p-3">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{actuator.descriptor}</span> <span className="flex items-center gap-2 font-medium text-foreground">
<span className={transmitting ? "bp-gradient-text font-semibold" : "text-muted-foreground"}> <span className="bp-led" data-on={transmitting} aria-hidden />
{actuator.descriptor}
</span>
<span className="bp-readout text-sm text-muted-foreground data-[on=true]:text-primary" data-on={transmitting}>
{Math.round(value * 100)}% {Math.round(value * 100)}%
</span> </span>
</div> </div>
<Slider <Slider value={[value * 100]} min={0} max={100} step={1} onValueChange={([v]) => onChange(v / 100)} />
value={[value * 100]}
min={0}
max={100}
step={1}
onValueChange={([v]) => onChange(v / 100)}
/>
</div> </div>
); );
} }
+5 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider"; import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
@@ -22,13 +22,14 @@ export function DeviceCard({
}: DeviceCardProps) { }: DeviceCardProps) {
return ( return (
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle>{device.displayName ?? device.name}</CardTitle> <span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<Button variant="outline" size="sm" onClick={onStop}> <Button variant="outline" size="sm" onClick={onStop}>
Stop Stop
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <div className="bp-hairline mx-4" />
<CardContent className="flex flex-col gap-3 pt-3">
{device.actuators.length === 0 ? ( {device.actuators.length === 0 ? (
<p className="text-sm text-muted-foreground">No controllable actuators reported.</p> <p className="text-sm text-muted-foreground">No controllable actuators reported.</p>
) : ( ) : (
+2 -2
View File
@@ -66,8 +66,8 @@ export function DevicesTable({ devices }: { devices: DeviceRow[] }) {
<TableCell> <TableCell>
<DeviceNameCell device={d} /> <DeviceNameCell device={d} />
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{d.bleName}</TableCell> <TableCell className="bp-readout text-muted-foreground">{d.bleName}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"} {d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"}
</TableCell> </TableCell>
</TableRow> </TableRow>
+42
View File
@@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
interface BrandMarkProps {
size?: number;
className?: string;
}
/** Inline twin of app/icon.svg (the favicon) - kept as markup, not an <img>, so it can be sized/animated with CSS. */
export function BrandMark({ size = 28, className }: BrandMarkProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn("shrink-0", className)}
aria-hidden="true"
>
<defs>
<linearGradient id="bpGradMark" x1="2" y1="2" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ff6fb0" />
<stop offset="52%" stopColor="#b34bde" />
<stop offset="100%" stopColor="#4f7fe0" />
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bpGradMark)" />
<path
d="M16 22.6c-.3 0-.5-.1-.7-.3C11.7 19 8 15.6 8 12.3 8 9.9 9.9 8 12.3 8c1.3 0 2.6.6 3.4 1.7.1.1.2.1.3 0 .8-1.1 2.1-1.7 3.4-1.7C21.8 8 23.7 9.9 23.7 12.3c0 3.3-3.7 6.7-7.3 10-.2.2-.4.3-.7.3z"
fill="rgba(255,255,255,0.22)"
/>
<polyline
points="6,15 10,15 12,9 15,21 18,13 20,15 26,15"
fill="none"
stroke="#ffffff"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+4 -9
View File
@@ -8,15 +8,10 @@ export function ConnectionStatus() {
const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length); const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length);
return ( return (
<div className="flex items-center gap-2 rounded-full border border-border bg-card/60 px-3 py-1 text-xs"> <div className="flex items-center gap-2 rounded-md border border-border bg-card/60 px-3 py-1 text-xs">
<span <span className={cn("bp-led", connected && "bp-pulse")} data-on={connected} aria-hidden />
className={cn( <span className="bp-readout text-muted-foreground">
"size-2 rounded-full", {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "not connected"}
connected ? "bg-primary bp-pulse" : "bg-muted-foreground/50",
)}
/>
<span className="text-muted-foreground">
{connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "Not connected"}
</span> </span>
</div> </div>
); );
+7 -5
View File
@@ -7,6 +7,7 @@ import { Menu, Moon, Sun, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ConnectionStatus } from "./ConnectionStatus"; import { ConnectionStatus } from "./ConnectionStatus";
import { BrandMark } from "./BrandMark";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const LINKS = [ const LINKS = [
@@ -28,10 +29,10 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
href={link.href} href={link.href}
onClick={onNavigate} onClick={onNavigate}
className={cn( className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors", "border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors",
pathname === link.href pathname === link.href
? "bg-primary/15 text-primary" ? "border-primary text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "border-transparent text-muted-foreground hover:text-foreground",
)} )}
> >
{link.label} {link.label}
@@ -54,8 +55,9 @@ export function NavBar() {
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-lg"> <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"> <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"> <Link href="/" className="mr-2 flex items-center gap-2">
sexy <BrandMark size={26} />
<span className="font-heading bp-gradient-text text-lg font-semibold">SEXY</span>
</Link> </Link>
<nav className="hidden items-center gap-1 md:flex"> <nav className="hidden items-center gap-1 md:flex">
+5 -3
View File
@@ -64,11 +64,13 @@ export function RecordingsTable({ recordings }: { recordings: RecordingRow[] })
{r.name} {r.name}
</Link> </Link>
</TableCell> </TableCell>
<TableCell>{formatDuration(r.durationMs)}</TableCell> <TableCell className="bp-readout">{formatDuration(r.durationMs)}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{r.playCount}</Badge> <Badge variant="secondary" className="bp-readout">
{r.playCount}
</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell> </TableCell>
<TableCell className="flex justify-end gap-1"> <TableCell className="flex justify-end gap-1">
+16 -7
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
export interface TimelineRow { export interface TimelineRow {
@@ -46,27 +46,36 @@ export function SessionTimelineChart({ timeline }: { timeline: TimelineRow[] })
return ( return (
<ChartContainer config={config} className="h-64 w-full"> <ChartContainer config={config} className="h-64 w-full">
<LineChart data={data}> <AreaChart data={data}>
<CartesianGrid vertical={false} /> <defs>
{series.map(([sessionDeviceId]) => (
<linearGradient key={sessionDeviceId} id={`fill-${seriesKey(sessionDeviceId)}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${seriesKey(sessionDeviceId)})`} stopOpacity={0.5} />
<stop offset="95%" stopColor={`var(--color-${seriesKey(sessionDeviceId)})`} stopOpacity={0.03} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis <XAxis
dataKey="bucket" dataKey="bucket"
tickFormatter={(v: number) => `${Math.round(v / 1000)}s`} tickFormatter={(v: number) => `${Math.round(v / 1000)}s`}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
className="bp-readout text-xs"
/> />
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} /> <YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} className="bp-readout text-xs" />
<ChartTooltip content={<ChartTooltipContent />} /> <ChartTooltip content={<ChartTooltipContent />} />
{series.map(([sessionDeviceId]) => ( {series.map(([sessionDeviceId]) => (
<Line <Area
key={sessionDeviceId} key={sessionDeviceId}
type="monotone" type="monotone"
dataKey={seriesKey(sessionDeviceId)} dataKey={seriesKey(sessionDeviceId)}
stroke={`var(--color-${seriesKey(sessionDeviceId)})`} stroke={`var(--color-${seriesKey(sessionDeviceId)})`}
strokeWidth={2} strokeWidth={2}
dot={false} fill={`url(#fill-${seriesKey(sessionDeviceId)})`}
/> />
))} ))}
</LineChart> </AreaChart>
</ChartContainer> </ChartContainer>
); );
} }
+4 -2
View File
@@ -68,8 +68,10 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
<Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge> <Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{s.status}</TableCell> <TableCell className="text-muted-foreground">{s.status}</TableCell>
<TableCell className="text-muted-foreground">{new Date(s.startedAt).toLocaleString()}</TableCell> <TableCell className="bp-readout text-muted-foreground">
<TableCell>{formatDuration(s.durationMs)}</TableCell> {new Date(s.startedAt).toLocaleString()}
</TableCell>
<TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete"> <Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete">
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
+4 -4
View File
@@ -30,10 +30,10 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
{devices.map((d) => ( {devices.map((d) => (
<TableRow key={d.deviceId}> <TableRow key={d.deviceId}>
<TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell> <TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell>
<TableCell>{d.sessionCount}</TableCell> <TableCell className="bp-readout">{d.sessionCount}</TableCell>
<TableCell>{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell> <TableCell className="bp-readout">{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
<TableCell>{d.commandCount}</TableCell> <TableCell className="bp-readout">{d.commandCount}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"} {d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
</TableCell> </TableCell>
</TableRow> </TableRow>
+5 -5
View File
@@ -16,13 +16,13 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Recordings</CardTitle> <CardTitle className="text-sm text-muted-foreground">Recordings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{stats.count}</CardContent> <CardContent className="bp-readout text-3xl">{stats.count}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle> <CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl"> <CardContent className="bp-readout text-3xl">
{Math.round(stats.avgDurationMs / 1000)}s {Math.round(stats.avgDurationMs / 1000)}s
</CardContent> </CardContent>
</Card> </Card>
@@ -30,7 +30,7 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total plays</CardTitle> <CardTitle className="text-sm text-muted-foreground">Total plays</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{stats.totalPlayCount}</CardContent> <CardContent className="bp-readout text-3xl">{stats.totalPlayCount}</CardContent>
</Card> </Card>
</div> </div>
@@ -47,8 +47,8 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
{stats.list.map((r) => ( {stats.list.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell> <TableCell className="font-medium">{r.name}</TableCell>
<TableCell>{r.playCount}</TableCell> <TableCell className="bp-readout">{r.playCount}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell> </TableCell>
</TableRow> </TableRow>
+3 -3
View File
@@ -23,19 +23,19 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary })
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle> <CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{summary.count}</CardContent> <CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle> <CardTitle className="text-sm text-muted-foreground">Total time</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl">{formatHours(summary.totalDurationMs)}</CardContent> <CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle> <CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="font-display text-3xl"> <CardContent className="bp-readout text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m {Math.round(summary.avgDurationMs / 60_000)}m
</CardContent> </CardContent>
</Card> </Card>
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function proxy(req: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|login|api/auth/login|api/health).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|login|api/auth/login|api/health).*)"],
}; };