Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [secret, setSecret] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setSubmitting(false);
|
||||
setError("Incorrect secret.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(searchParams.get("from") ?? "/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="secret">Access secret</Label>
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" disabled={submitting || secret.length === 0} className="w-full">
|
||||
{submitting ? "Checking..." : "Enter"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { ActuatorInfo } from "@/lib/buttplug/types";
|
||||
|
||||
interface ActuatorSliderProps {
|
||||
actuator: ActuatorInfo;
|
||||
value: number;
|
||||
transmitting: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">{actuator.descriptor}</span>
|
||||
<span className={transmitting ? "bp-gradient-text font-semibold" : "text-muted-foreground"}>
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[value * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([v]) => onChange(v / 100)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
disconnectAll,
|
||||
getButtplugClientHandle,
|
||||
getDevice,
|
||||
isWebBluetoothSupported,
|
||||
startScanning,
|
||||
stopScanning,
|
||||
} from "@/lib/buttplug/client";
|
||||
import { buildOutputCommand, findFeature } from "@/lib/buttplug/commands";
|
||||
import type { ButtplugRuntime } from "@/lib/buttplug/commands";
|
||||
import { throttledSend } from "@/lib/buttplug/throttle";
|
||||
import { eventBuffer } from "@/lib/buttplug/event-buffer";
|
||||
import { actuatorKey, useButtplugStore } from "@/lib/buttplug/store";
|
||||
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
|
||||
import { DeviceScanPanel } from "./DeviceScanPanel";
|
||||
import { DeviceCard } from "./DeviceCard";
|
||||
import { RecordControls } from "./RecordControls";
|
||||
import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface ActivePlaySession {
|
||||
id: number;
|
||||
startedAt: number;
|
||||
sessionDeviceIdByDeviceIndex: Map<number, number>;
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ButtplugConsole() {
|
||||
const supported = isWebBluetoothSupported();
|
||||
const connected = useButtplugStore((s) => s.connected);
|
||||
const scanning = useButtplugStore((s) => s.scanning);
|
||||
const devices = useButtplugStore((s) => s.devices);
|
||||
const actuatorValues = useButtplugStore((s) => s.actuatorValues);
|
||||
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
|
||||
const storeError = useButtplugStore((s) => s.error);
|
||||
|
||||
const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [sessionBusy, setSessionBusy] = useState(false);
|
||||
const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null);
|
||||
const [transmittingKey, setTransmittingKey] = useState<string | null>(null);
|
||||
|
||||
const runtimeRef = useRef<ButtplugRuntime | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
const interval = setInterval(() => setElapsedMs(Date.now() - activeSession.startedAt), 250);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void disconnectAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function ensureRuntime(): Promise<ButtplugRuntime> {
|
||||
if (runtimeRef.current) return runtimeRef.current;
|
||||
const { runtime } = await getButtplugClientHandle();
|
||||
runtimeRef.current = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
try {
|
||||
await ensureRuntime();
|
||||
await startScanning();
|
||||
} catch {
|
||||
toast.error("Could not start scanning - check Bluetooth is on and permitted.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartSession() {
|
||||
setSessionBusy(true);
|
||||
try {
|
||||
const deviceList = Object.values(devices);
|
||||
const body = {
|
||||
kind: "live" as const,
|
||||
devices: deviceList.map((d) => ({
|
||||
slotLabel: d.displayName ?? d.name,
|
||||
bleName: d.name,
|
||||
capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length },
|
||||
})),
|
||||
};
|
||||
const res = await fetch("/api/play-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error("failed to start session");
|
||||
const data: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
|
||||
await res.json();
|
||||
|
||||
const sessionDeviceIdByDeviceIndex = new Map<number, number>();
|
||||
deviceList.forEach((d, i) => {
|
||||
const row = data.sessionDevices[i];
|
||||
if (row) sessionDeviceIdByDeviceIndex.set(d.index, row.id);
|
||||
});
|
||||
|
||||
eventBuffer.start(data.session.id, data.session.startedAt);
|
||||
sessionDeviceIdByDeviceIndex.forEach((sessionDeviceId, deviceIndex) => {
|
||||
eventBuffer.registerSessionDevice(deviceIndex, sessionDeviceId);
|
||||
});
|
||||
|
||||
setActiveSession({
|
||||
id: data.session.id,
|
||||
startedAt: data.session.startedAt,
|
||||
sessionDeviceIdByDeviceIndex,
|
||||
});
|
||||
setElapsedMs(0);
|
||||
} catch {
|
||||
toast.error("Could not start session.");
|
||||
} finally {
|
||||
setSessionBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndSession() {
|
||||
if (!activeSession) return;
|
||||
setSessionBusy(true);
|
||||
eventBuffer.stop();
|
||||
await fetch(`/api/play-sessions/${activeSession.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "completed" }),
|
||||
});
|
||||
setSavePromptSessionId(activeSession.id);
|
||||
setActiveSession(null);
|
||||
setSessionBusy(false);
|
||||
}
|
||||
|
||||
async function handleActuatorChange(device: ConnectedDeviceInfo, featureIndex: number, value: number) {
|
||||
const key = actuatorKey(device.index, featureIndex);
|
||||
setActuatorValue(device.index, featureIndex, value);
|
||||
|
||||
const actuator = device.actuators.find((a) => a.featureIndex === featureIndex);
|
||||
if (!actuator) return;
|
||||
|
||||
throttledSend(
|
||||
key,
|
||||
async (v) => {
|
||||
try {
|
||||
const runtime = await ensureRuntime();
|
||||
const liveDevice = await getDevice(device.index);
|
||||
const feature = liveDevice && findFeature(liveDevice, featureIndex);
|
||||
if (!liveDevice || !feature) return;
|
||||
setTransmittingKey(key);
|
||||
const cmd = buildOutputCommand(runtime, actuator, v);
|
||||
await feature.runOutput(cmd);
|
||||
eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v });
|
||||
} finally {
|
||||
setTransmittingKey((k) => (k === key ? null : k));
|
||||
}
|
||||
},
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopDevice(device: ConnectedDeviceInfo) {
|
||||
const liveDevice = await getDevice(device.index);
|
||||
await liveDevice?.stop();
|
||||
eventBuffer.record({ deviceIndex: device.index, commandType: "stop", featureIndex: 0, value: 0 });
|
||||
device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0));
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<Card className="bp-glass">
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
This browser doesn't support Web Bluetooth, so device control isn't available here. Use a
|
||||
Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can't run
|
||||
Web Bluetooth at all, on any browser - this is an Apple platform limitation, not a bug.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
|
||||
<RecordControls
|
||||
active={activeSession !== null}
|
||||
elapsedLabel={formatElapsed(elapsedMs)}
|
||||
disabled={!connected || Object.keys(devices).length === 0}
|
||||
busy={sessionBusy}
|
||||
onStart={handleStartSession}
|
||||
onEnd={handleEndSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storeError && <p className="text-sm text-destructive">{storeError}</p>}
|
||||
|
||||
{Object.keys(devices).length === 0 ? (
|
||||
<Card className="bp-glass">
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
No devices connected yet. Scan to discover nearby toys, then select one from your browser's
|
||||
pairing prompt.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Object.values(devices).map((device) => (
|
||||
<DeviceCard
|
||||
key={device.index}
|
||||
device={device}
|
||||
actuatorValues={Object.fromEntries(
|
||||
device.actuators.map((a) => [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]),
|
||||
)}
|
||||
transmittingFeatureIndex={
|
||||
transmittingKey?.startsWith(`${device.index}:`)
|
||||
? Number(transmittingKey.split(":")[1])
|
||||
: null
|
||||
}
|
||||
onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)}
|
||||
onStop={() => void handleStopDevice(device)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SaveRecordingDialog
|
||||
playSessionId={savePromptSessionId}
|
||||
onClose={() => setSavePromptSessionId(null)}
|
||||
onSaved={() => setSavePromptSessionId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ButtplugConsole = dynamic(() => import("./ButtplugConsole").then((m) => m.ButtplugConsole), {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-64 w-full" />,
|
||||
});
|
||||
|
||||
export function ButtplugConsoleLoader() {
|
||||
return <ButtplugConsole />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ActuatorSlider } from "./ActuatorSlider";
|
||||
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: ConnectedDeviceInfo;
|
||||
actuatorValues: Record<number, number>;
|
||||
transmittingFeatureIndex: number | null;
|
||||
onActuatorChange: (featureIndex: number, value: number) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function DeviceCard({
|
||||
device,
|
||||
actuatorValues,
|
||||
transmittingFeatureIndex,
|
||||
onActuatorChange,
|
||||
onStop,
|
||||
}: DeviceCardProps) {
|
||||
return (
|
||||
<Card className="bp-glass">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{device.displayName ?? device.name}</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={onStop}>
|
||||
Stop
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{device.actuators.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No controllable actuators reported.</p>
|
||||
) : (
|
||||
device.actuators.map((actuator) => (
|
||||
<ActuatorSlider
|
||||
key={actuator.featureIndex}
|
||||
actuator={actuator}
|
||||
value={actuatorValues[actuator.featureIndex] ?? 0}
|
||||
transmitting={transmittingFeatureIndex === actuator.featureIndex}
|
||||
onChange={(value) => onActuatorChange(actuator.featureIndex, value)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bluetooth, LoaderCircle } from "lucide-react";
|
||||
|
||||
interface DeviceScanPanelProps {
|
||||
scanning: boolean;
|
||||
onScan: () => void;
|
||||
onStopScan: () => void;
|
||||
}
|
||||
|
||||
export function DeviceScanPanel({ scanning, onScan, onStopScan }: DeviceScanPanelProps) {
|
||||
return (
|
||||
<Button onClick={scanning ? onStopScan : onScan} variant={scanning ? "outline" : "default"}>
|
||||
{scanning ? (
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" /> Stop scanning
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bluetooth className="size-4" /> Scan for devices
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Circle, Square } from "lucide-react";
|
||||
|
||||
interface RecordControlsProps {
|
||||
active: boolean;
|
||||
elapsedLabel: string;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
onStart: () => void;
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) {
|
||||
if (active) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
|
||||
Session live · {elapsedLabel}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={onEnd} disabled={busy}>
|
||||
<Square className="size-3.5" /> End session
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onStart} disabled={disabled || busy} size="sm">
|
||||
<Circle className="size-3.5" /> Start session
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface DeviceRow {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
bleName: string;
|
||||
lastConnectedAt: number | null;
|
||||
}
|
||||
|
||||
function DeviceNameCell({ device }: { device: DeviceRow }) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState(device.displayName ?? device.bleName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (value.trim().length === 0) return;
|
||||
setSaving(true);
|
||||
const res = await fetch(`/api/devices/${device.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName: value.trim() }),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
toast.success("Renamed");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Could not rename device");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 max-w-48" />
|
||||
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DevicesTable({ devices }: { devices: DeviceRow[] }) {
|
||||
if (devices.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No devices seen yet - connect one from the Control page.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Display name</TableHead>
|
||||
<TableHead>Advertised name</TableHead>
|
||||
<TableHead>Last connected</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>
|
||||
<DeviceNameCell device={d} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{d.bleName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useButtplugStore } from "@/lib/buttplug/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const connected = useButtplugStore((s) => s.connected);
|
||||
const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card/60 px-3 py-1 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
connected ? "bg-primary bp-pulse" : "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Menu, Moon, Sun, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { ConnectionStatus } from "./ConnectionStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/control", label: "Control" },
|
||||
{ href: "/recordings", label: "Recordings" },
|
||||
{ href: "/sessions", label: "Sessions" },
|
||||
{ href: "/stats", label: "Stats" },
|
||||
{ href: "/devices", label: "Devices" },
|
||||
];
|
||||
|
||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<>
|
||||
{LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
pathname === link.href
|
||||
? "bg-primary/15 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavBar() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4">
|
||||
<Link href="/" className="font-display bp-gradient-text mr-2 text-lg font-semibold">
|
||||
sexy
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<NavLinks />
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ConnectionStatus />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="hidden size-4 dark:block" />
|
||||
<Moon className="block size-4 dark:hidden" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Log out" onClick={handleLogout}>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64">
|
||||
<SheetTitle className="px-4 pt-4">Menu</SheetTitle>
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
<NavLinks />
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap";
|
||||
import type { RecordingDeviceSlot } from "@/lib/db/schema";
|
||||
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
|
||||
|
||||
interface DeviceRemapDialogProps {
|
||||
open: boolean;
|
||||
deviceSlots: RecordingDeviceSlot[];
|
||||
connectedDevices: ConnectedDeviceInfo[];
|
||||
onCancel: () => void;
|
||||
onConfirm: (mapping: Map<number, number>) => void;
|
||||
}
|
||||
|
||||
export function DeviceRemapDialog({
|
||||
open,
|
||||
deviceSlots,
|
||||
connectedDevices,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeviceRemapDialogProps) {
|
||||
const autoMapped = autoMapDeviceSlots(deviceSlots, connectedDevices);
|
||||
const [assignments, setAssignments] = useState<Record<number, number | null>>(
|
||||
Object.fromEntries(deviceSlots.map((slot, i) => [slot.sourceSessionDeviceId, autoMapped[i]?.matchedDeviceIndex ?? null])),
|
||||
);
|
||||
|
||||
const allAssigned = deviceSlots.every((s) => assignments[s.sourceSessionDeviceId] !== null);
|
||||
|
||||
function handleConfirm() {
|
||||
const mapping = new Map<number, number>();
|
||||
for (const slot of deviceSlots) {
|
||||
const deviceIndex = assignments[slot.sourceSessionDeviceId];
|
||||
if (deviceIndex !== null && deviceIndex !== undefined) mapping.set(slot.sourceSessionDeviceId, deviceIndex);
|
||||
}
|
||||
onConfirm(mapping);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match devices for replay</DialogTitle>
|
||||
<DialogDescription>
|
||||
Web Bluetooth doesn't expose a stable device id across sessions, so match each recorded
|
||||
device to a currently-connected one. Two identically-named devices can't be told apart
|
||||
automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
{deviceSlots.map((slot) => (
|
||||
<div key={slot.sourceSessionDeviceId} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{slot.slotLabel}</span>
|
||||
<Select
|
||||
value={assignments[slot.sourceSessionDeviceId]?.toString() ?? undefined}
|
||||
onValueChange={(v) =>
|
||||
setAssignments((prev) => ({ ...prev, [slot.sourceSessionDeviceId]: Number(v) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-48">
|
||||
<SelectValue placeholder="Unmatched" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connectedDevices.map((d) => (
|
||||
<SelectItem key={d.index} value={d.index.toString()}>
|
||||
{d.displayName ?? d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!allAssigned}>
|
||||
Start replay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Play, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface RecordingRow {
|
||||
id: number;
|
||||
name: string;
|
||||
durationMs: number;
|
||||
playCount: number;
|
||||
lastPlayedAt: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function RecordingsTable({ recordings }: { recordings: RecordingRow[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
const res = await fetch(`/api/recordings/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
toast.success("Recording deleted");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Could not delete recording");
|
||||
}
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recordings yet - start a session on the Control page and save it when you're done.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Plays</TableHead>
|
||||
<TableHead>Last played</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recordings.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/recordings/${r.id}`} className="hover:underline">
|
||||
{r.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(r.durationMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{r.playCount}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="flex justify-end gap-1">
|
||||
<Button asChild variant="ghost" size="icon-sm">
|
||||
<Link href={`/recordings/${r.id}/replay`} aria-label="Replay">
|
||||
<Play className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(r.id)} aria-label="Delete">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
|
||||
import { DeviceRemapDialog } from "./DeviceRemapDialog";
|
||||
import { getButtplugClientHandle, startScanning, stopScanning } from "@/lib/buttplug/client";
|
||||
import { eventBuffer } from "@/lib/buttplug/event-buffer";
|
||||
import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player";
|
||||
import { useButtplugStore } from "@/lib/buttplug/store";
|
||||
import type { ActuatorInfo } from "@/lib/buttplug/types";
|
||||
import type { RecordingDeviceSlot } from "@/lib/db/schema";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
|
||||
interface RecordingResponse {
|
||||
recording: {
|
||||
id: number;
|
||||
name: string;
|
||||
durationMs: number;
|
||||
deviceSlots: RecordingDeviceSlot[];
|
||||
};
|
||||
events: RecordingEventRow[];
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ReplayPlayer({ recordingId }: { recordingId: number }) {
|
||||
const router = useRouter();
|
||||
const scanning = useButtplugStore((s) => s.scanning);
|
||||
const devices = useButtplugStore((s) => s.devices);
|
||||
const connectedDevices = useMemo(() => Object.values(devices), [devices]);
|
||||
|
||||
const [data, setData] = useState<RecordingResponse | null>(null);
|
||||
const [showRemap, setShowRemap] = useState(false);
|
||||
const [player, setPlayer] = useState<RecordingPlayer | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/recordings/${recordingId}`)
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => toast.error("Could not load recording"));
|
||||
}, [recordingId]);
|
||||
|
||||
async function handleConfirmRemap(mapping: Map<number, number>) {
|
||||
if (!data) return;
|
||||
setShowRemap(false);
|
||||
|
||||
const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
|
||||
const body = {
|
||||
kind: "replay" as const,
|
||||
replayedRecordingId: data.recording.id,
|
||||
devices: orderedSlots.map((slot) => {
|
||||
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
|
||||
const device = connectedDevices.find((d) => d.index === deviceIndex)!;
|
||||
return { slotLabel: slot.slotLabel, bleName: device.name };
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await fetch("/api/play-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error("Could not start replay session");
|
||||
return;
|
||||
}
|
||||
const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
|
||||
await res.json();
|
||||
|
||||
eventBuffer.start(result.session.id, result.session.startedAt);
|
||||
const sessionDeviceIdToDeviceIndex = new Map<number, number>();
|
||||
orderedSlots.forEach((slot, i) => {
|
||||
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
|
||||
const newSessionDeviceId = result.sessionDevices[i]?.id;
|
||||
if (newSessionDeviceId !== undefined) {
|
||||
eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId);
|
||||
sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex);
|
||||
}
|
||||
});
|
||||
|
||||
const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>(
|
||||
connectedDevices.map((d) => [d.index, d.actuators]),
|
||||
);
|
||||
|
||||
const { runtime } = await getButtplugClientHandle();
|
||||
const instance = new RecordingPlayer({
|
||||
events: data.events,
|
||||
sessionDeviceIdToDeviceIndex,
|
||||
actuatorsByDeviceIndex,
|
||||
runtime,
|
||||
onProgress: (elapsed) => setElapsedMs(elapsed),
|
||||
onComplete: async () => {
|
||||
setPlaying(false);
|
||||
eventBuffer.stop();
|
||||
await fetch(`/api/play-sessions/${result.session.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "completed" }),
|
||||
});
|
||||
toast.success("Replay finished");
|
||||
},
|
||||
});
|
||||
setPlayer(instance);
|
||||
instance.play();
|
||||
setPlaying(true);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <p className="text-sm text-muted-foreground">Loading recording...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle>{data.recording.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{!player ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect the devices you want to replay onto, then match them to the recording's
|
||||
original device slots.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await getButtplugClientHandle();
|
||||
setShowRemap(true);
|
||||
}}
|
||||
disabled={connectedDevices.length === 0}
|
||||
>
|
||||
Match devices & replay
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Slider
|
||||
value={[elapsedMs]}
|
||||
max={data.recording.durationMs}
|
||||
onValueChange={([v]) => player.seek(v)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (playing) {
|
||||
player.pause();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
player.play();
|
||||
setPlaying(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeviceRemapDialog
|
||||
open={showRemap}
|
||||
deviceSlots={data.recording.deviceSlots}
|
||||
connectedDevices={connectedDevices}
|
||||
onCancel={() => setShowRemap(false)}
|
||||
onConfirm={(mapping) => void handleConfirmRemap(mapping)}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/recordings")}>
|
||||
Back to recordings
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ReplayPlayer = dynamic(() => import("./ReplayPlayer").then((m) => m.ReplayPlayer), {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-64 w-full" />,
|
||||
});
|
||||
|
||||
export function ReplayPlayerLoader({ recordingId }: { recordingId: number }) {
|
||||
return <ReplayPlayer recordingId={recordingId} />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SaveRecordingDialogProps {
|
||||
playSessionId: number | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRecordingDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (!playSessionId || name.trim().length === 0) return;
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/recordings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourcePlaySessionId: playSessionId, name: name.trim(), description }),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
toast.success("Recording saved");
|
||||
onSaved();
|
||||
} else {
|
||||
toast.error("Could not save recording");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={playSessionId !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save session as a recording</DialogTitle>
|
||||
<DialogDescription>Give it a name so you can find and replay it later.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="recording-name">Name</Label>
|
||||
<Input id="recording-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="recording-description">Description (optional)</Label>
|
||||
<Input
|
||||
id="recording-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || name.trim().length === 0}>
|
||||
{saving ? "Saving..." : "Save recording"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
|
||||
|
||||
export interface TimelineRow {
|
||||
bucket: number;
|
||||
sessionDeviceId: number;
|
||||
slotLabel: string;
|
||||
avgValue: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
|
||||
|
||||
// Chart config keys become raw CSS custom-property names (--color-<key>), so
|
||||
// they must be safe identifiers - a user-chosen device display name isn't.
|
||||
// Use the numeric session_device_id as the key, slotLabel only as display text.
|
||||
function seriesKey(sessionDeviceId: number): string {
|
||||
return `device_${sessionDeviceId}`;
|
||||
}
|
||||
|
||||
export function SessionTimelineChart({ timeline }: { timeline: TimelineRow[] }) {
|
||||
const series = [...new Map(timeline.map((r) => [r.sessionDeviceId, r.slotLabel])).entries()];
|
||||
const buckets = [...new Set(timeline.map((r) => r.bucket))].sort((a, b) => a - b);
|
||||
|
||||
const data = buckets.map((bucket) => {
|
||||
const row: Record<string, number> = { bucket };
|
||||
for (const [sessionDeviceId] of series) {
|
||||
const match = timeline.find((r) => r.bucket === bucket && r.sessionDeviceId === sessionDeviceId);
|
||||
row[seriesKey(sessionDeviceId)] = match ? Math.round(match.avgValue * 100) : 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const config: ChartConfig = Object.fromEntries(
|
||||
series.map(([sessionDeviceId, slotLabel], i) => [
|
||||
seriesKey(sessionDeviceId),
|
||||
{ label: slotLabel, color: SERIES_COLORS[i % SERIES_COLORS.length] },
|
||||
]),
|
||||
);
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No device activity recorded for this session.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} className="h-64 w-full">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickFormatter={(v: number) => `${Math.round(v / 1000)}s`}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{series.map(([sessionDeviceId]) => (
|
||||
<Line
|
||||
key={sessionDeviceId}
|
||||
type="monotone"
|
||||
dataKey={seriesKey(sessionDeviceId)}
|
||||
stroke={`var(--color-${seriesKey(sessionDeviceId)})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface SessionRow {
|
||||
id: number;
|
||||
name: string | null;
|
||||
kind: "live" | "replay";
|
||||
status: "active" | "completed" | "aborted";
|
||||
startedAt: number;
|
||||
durationMs: number | null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return "-";
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
toast.success("Session deleted");
|
||||
router.refresh();
|
||||
} else if (res.status === 409) {
|
||||
toast.error("A saved recording still references this session");
|
||||
} else {
|
||||
toast.error("Could not delete session");
|
||||
}
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No sessions yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session</TableHead>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/sessions/${s.id}`} className="hover:underline">
|
||||
{s.name ?? `Session #${s.id}`}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.status}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{new Date(s.startedAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{formatDuration(s.durationMs)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export interface DeviceUsageRow {
|
||||
deviceId: number;
|
||||
displayName: string | null;
|
||||
bleName: string;
|
||||
sessionCount: number;
|
||||
totalActiveMs: number;
|
||||
commandCount: number;
|
||||
lastUsedAt: number | null;
|
||||
}
|
||||
|
||||
export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
|
||||
if (devices.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No device activity yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Active time</TableHead>
|
||||
<TableHead>Commands</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.deviceId}>
|
||||
<TableCell className="font-medium">{d.displayName ?? d.bleName}</TableCell>
|
||||
<TableCell>{d.sessionCount}</TableCell>
|
||||
<TableCell>{(d.totalActiveMs / 60_000).toFixed(1)}m</TableCell>
|
||||
<TableCell>{d.commandCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export interface RecordingLibrarySummary {
|
||||
count: number;
|
||||
avgDurationMs: number;
|
||||
totalPlayCount: number;
|
||||
list: { id: number; name: string; durationMs: number; playCount: number; lastPlayedAt: number | null }[];
|
||||
}
|
||||
|
||||
export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummary }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Recordings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">{stats.count}</CardContent>
|
||||
</Card>
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">
|
||||
{Math.round(stats.avgDurationMs / 1000)}s
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Total plays</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">{stats.totalPlayCount}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{stats.list.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Recording</TableHead>
|
||||
<TableHead>Plays</TableHead>
|
||||
<TableHead>Last played</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats.list.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.name}</TableCell>
|
||||
<TableCell>{r.playCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export interface SessionsSummary {
|
||||
count: number;
|
||||
totalDurationMs: number;
|
||||
avgDurationMs: number;
|
||||
byKind: { kind: "live" | "replay"; count: number; totalDurationMs: number }[];
|
||||
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
|
||||
}
|
||||
|
||||
function formatHours(ms: number): string {
|
||||
return `${(ms / 3_600_000).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
|
||||
const liveCount = summary.byKind.find((k) => k.kind === "live")?.count ?? 0;
|
||||
const replayCount = summary.byKind.find((k) => k.kind === "replay")?.count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">{summary.count}</CardContent>
|
||||
</Card>
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
|
||||
</Card>
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-display text-3xl">
|
||||
{Math.round(summary.avgDurationMs / 60_000)}m
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{liveCount} live · {replayCount} replay
|
||||
</p>
|
||||
{summary.durationPerDevice.length > 0 && (
|
||||
<Card className="bp-glass">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Duration per device</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{summary.durationPerDevice.map((d) => (
|
||||
<div key={d.deviceId} className="flex items-center justify-between text-sm">
|
||||
<span>{d.displayName ?? d.bleName}</span>
|
||||
<span className="text-muted-foreground">{formatHours(d.totalActiveMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { size?: "sm" | "default" }) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
Reference in New Issue
Block a user