Add animated glass background, breadcrumbs, and brand mark polish
Cards now sit on an animated signature-gradient background and use a translucent, blurred bp-glass surface so it shines through; extended bp-glass to every stat surface on the Stats page and to its tab switcher. Adds a Breadcrumbs component below the header on all app pages. Also: drop the gradient text fill from the wordmark/heading, animate the BrandMark's heart and curve on the dashboard hero instead of a plain pulse ring, recenter the heart/curve artwork in icon.svg and BrandMark, close the mobile nav menu on link click, remove the redundant "back to recordings" button, and match the "Start session"/"Scan for devices" button sizes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ export function RecordControls({ active, elapsedLabel, disabled, busy, onStart,
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onStart} disabled={disabled || busy} size="sm">
|
||||
<Button onClick={onStart} disabled={disabled || busy}>
|
||||
<Circle className="size-3.5" /> Start session
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,12 @@ import { cn } from "@/lib/utils";
|
||||
interface BrandMarkProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Beats the heart glyph and makes the curve glow - used for the dashboard hero mark. */
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
export function BrandMark({ size = 28, className, animated = false }: BrandMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -25,18 +27,22 @@ export function BrandMark({ size = 28, className }: BrandMarkProps) {
|
||||
</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"
|
||||
/>
|
||||
<g transform="translate(0 0.7)">
|
||||
<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)"
|
||||
className={cn(animated && "bp-mark-heart")}
|
||||
/>
|
||||
<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"
|
||||
className={cn(animated && "bp-mark-curve")}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronRight, House } from "lucide-react";
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
control: "Control",
|
||||
recordings: "Recordings",
|
||||
sessions: "Sessions",
|
||||
stats: "Stats",
|
||||
devices: "Devices",
|
||||
};
|
||||
|
||||
const LEAF_LABELS: Record<string, string> = {
|
||||
replay: "Replay",
|
||||
};
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const crumbs = segments.reduce<{ label: string; href: string }[]>((acc, segment) => {
|
||||
const href = `${acc.at(-1)?.href ?? ""}/${segment}`;
|
||||
const label = /^\d+$/.test(segment) ? `#${segment}` : (LEAF_LABELS[segment] ?? SECTION_LABELS[segment] ?? segment);
|
||||
return [...acc, { label, href }];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="bp-glass mb-6 flex w-fit items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<Link href="/" className="flex items-center hover:text-foreground" aria-label="Dashboard">
|
||||
<House className="size-3.5" />
|
||||
</Link>
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1;
|
||||
return (
|
||||
<span key={crumb.href} className="flex items-center gap-1.5">
|
||||
<ChevronRight className="size-3.5 text-muted-foreground/50" />
|
||||
{isLast ? (
|
||||
<span className="font-medium text-foreground">{crumb.label}</span>
|
||||
) : (
|
||||
<Link href={crumb.href} className="hover:text-foreground">
|
||||
{crumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -45,6 +46,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
export function NavBar() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
@@ -57,7 +59,7 @@ export function NavBar() {
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4">
|
||||
<Link href="/" className="mr-2 flex items-center gap-2">
|
||||
<BrandMark size={26} />
|
||||
<span className="font-heading bp-gradient-text text-lg font-semibold">Sexy</span>
|
||||
<span className="font-heading text-lg font-semibold">Sexy</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
@@ -79,7 +81,7 @@ export function NavBar() {
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Sheet>
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
|
||||
<Menu className="size-4" />
|
||||
@@ -88,7 +90,7 @@ export function NavBar() {
|
||||
<SheetContent side="right" className="w-64">
|
||||
<SheetTitle className="px-4 pt-4">Menu</SheetTitle>
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
<NavLinks />
|
||||
<NavLinks onNavigate={() => setMenuOpen(false)} />
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"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";
|
||||
@@ -32,7 +31,6 @@ function formatTime(ms: number): string {
|
||||
}
|
||||
|
||||
export function ReplayPlayer({ recordingId }: { recordingId: number }) {
|
||||
const router = useRouter();
|
||||
const scanning = useButtplugStore((s) => s.scanning);
|
||||
const devices = useButtplugStore((s) => s.devices);
|
||||
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
|
||||
@@ -289,10 +287,6 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
|
||||
onCancel={() => setShowRemap(false)}
|
||||
onConfirm={(mapping) => void handleConfirmRemap(mapping)}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/recordings")}>
|
||||
Back to recordings
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export interface DeviceUsageRow {
|
||||
@@ -12,33 +13,41 @@ export interface DeviceUsageRow {
|
||||
|
||||
export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
|
||||
if (devices.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No device activity yet.</p>;
|
||||
return (
|
||||
<Card className="bp-glass">
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="bp-readout">{d.sessionCount}</TableCell>
|
||||
<TableCell className="bp-readout">{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
|
||||
<TableCell className="bp-readout">{d.commandCount}</TableCell>
|
||||
<TableCell className="bp-readout text-muted-foreground">
|
||||
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Card className="bp-glass">
|
||||
<CardContent>
|
||||
<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 className="bp-readout">{d.sessionCount}</TableCell>
|
||||
<TableCell className="bp-readout">{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
|
||||
<TableCell className="bp-readout">{d.commandCount}</TableCell>
|
||||
<TableCell className="bp-readout text-muted-foreground">
|
||||
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,26 +35,30 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
|
||||
</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 className="bp-readout">{r.playCount}</TableCell>
|
||||
<TableCell className="bp-readout text-muted-foreground">
|
||||
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Card className="bp-glass">
|
||||
<CardContent>
|
||||
<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 className="bp-readout">{r.playCount}</TableCell>
|
||||
<TableCell className="bp-readout text-muted-foreground">
|
||||
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,9 +40,11 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary })
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{liveCount} live · {replayCount} replay
|
||||
</p>
|
||||
<Card className="bp-glass">
|
||||
<CardContent className="py-3 text-sm text-muted-foreground">
|
||||
{liveCount} live · {replayCount} replay
|
||||
</CardContent>
|
||||
</Card>
|
||||
{summary.durationPerDevice.length > 0 && (
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user