Fix device/replay/session UX issues, add pagination, bump to 0.3.0
- Fix device rename input collapsing on mobile (fixed width vs w-full inside an auto-layout table column). - Add cascade-delete confirmation for sessions with a saved recording. - Fix header connection LED: derive state from scanning/device-count/ recording instead of the Buttplug client's raw connected flag; drop the label text and hide the indicator entirely when idle. - Add a per-device disconnect button (stop + remove from store, the closest equivalent Buttplug's protocol allows per device). - Fix replay ending early: duration now comes from the recording's actual durationMs, not the last event's timestamp. - Replay robustness: show which devices are being replayed to, reset actuators to zero on start/play/pause, fully disconnect devices and the whole client on stop/unmount, surface command failures via toast. - Stop flagging the header LED red during replay - recording is only for live sessions. - Add page-number pagination to recordings/sessions/devices lists. - Wordmark SEXY -> Sexy; add proper per-page <title>s including dynamic titles for recording/session detail and replay pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { devices, type DeviceCapabilities } from "@/lib/db/schema";
|
||||
import { PAGE_SIZE, type Page } from "@/lib/pagination";
|
||||
|
||||
export async function listDevices() {
|
||||
return db.select().from(devices).orderBy(devices.lastConnectedAt);
|
||||
}
|
||||
|
||||
export async function listDevicesPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof devices.$inferSelect>> {
|
||||
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(devices);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(devices)
|
||||
.orderBy(desc(devices.lastConnectedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
return { items, page, pageSize, total: count };
|
||||
}
|
||||
|
||||
export async function getDevice(id: number) {
|
||||
const [row] = await db.select().from(devices).where(eq(devices.id, id));
|
||||
return row;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { playSessions, sessionDevices, devices } from "@/lib/db/schema";
|
||||
import { playSessions, sessionDevices, devices, recordings } from "@/lib/db/schema";
|
||||
import { upsertDeviceByBleName } from "./devices";
|
||||
import { incrementPlayCount } from "./recordings";
|
||||
import type { DeviceCapabilities } from "@/lib/db/schema";
|
||||
import { PAGE_SIZE, type Page } from "@/lib/pagination";
|
||||
|
||||
export interface StartSessionDeviceInput {
|
||||
slotLabel: string;
|
||||
@@ -86,6 +87,23 @@ export async function listPlaySessions() {
|
||||
return db.select().from(playSessions).orderBy(playSessions.startedAt);
|
||||
}
|
||||
|
||||
export async function listPlaySessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof playSessions.$inferSelect>> {
|
||||
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(playSessions);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(playSessions)
|
||||
.orderBy(desc(playSessions.startedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
return { items, page, pageSize, total: count };
|
||||
}
|
||||
|
||||
/** Lightweight name-only lookup for page titles - avoids getPlaySessionDetail's device join. */
|
||||
export async function getPlaySessionName(id: number): Promise<string | null | undefined> {
|
||||
const [row] = await db.select({ name: playSessions.name }).from(playSessions).where(eq(playSessions.id, id));
|
||||
return row?.name;
|
||||
}
|
||||
|
||||
export async function getPlaySessionDetail(id: number) {
|
||||
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id));
|
||||
if (!session) return undefined;
|
||||
@@ -107,6 +125,9 @@ export async function getPlaySessionDetail(id: number) {
|
||||
return { session, devices: sessionDeviceRows };
|
||||
}
|
||||
|
||||
export async function deletePlaySession(id: number) {
|
||||
export async function deletePlaySession(id: number, options?: { cascade?: boolean }) {
|
||||
if (options?.cascade) {
|
||||
await db.delete(recordings).where(eq(recordings.sourcePlaySessionId, id));
|
||||
}
|
||||
await db.delete(playSessions).where(eq(playSessions.id, id));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema";
|
||||
import { getEventsForSession } from "./session-events";
|
||||
import { PAGE_SIZE, type Page } from "@/lib/pagination";
|
||||
|
||||
/**
|
||||
* A recording is a thin pointer over an already-captured play_session, not a
|
||||
@@ -60,6 +61,30 @@ export async function listRecordings() {
|
||||
return db.select().from(recordings).orderBy(desc(recordings.createdAt));
|
||||
}
|
||||
|
||||
export async function listRecordingsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof recordings.$inferSelect>> {
|
||||
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(recordings);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(recordings)
|
||||
.orderBy(desc(recordings.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
return { items, page, pageSize, total: count };
|
||||
}
|
||||
|
||||
export async function getRecordingsForSession(playSessionId: number) {
|
||||
return db
|
||||
.select({ id: recordings.id, name: recordings.name })
|
||||
.from(recordings)
|
||||
.where(eq(recordings.sourcePlaySessionId, playSessionId));
|
||||
}
|
||||
|
||||
/** Lightweight name-only lookup for page titles - avoids getRecording's event-table join. */
|
||||
export async function getRecordingName(id: number): Promise<string | undefined> {
|
||||
const [row] = await db.select({ name: recordings.name }).from(recordings).where(eq(recordings.id, id));
|
||||
return row?.name;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const [recording] = await db.select().from(recordings).where(eq(recordings.id, id));
|
||||
if (!recording) return undefined;
|
||||
|
||||
Reference in New Issue
Block a user