From a9e4ed6049de74f7a1d5dcc76a4972f82e2feac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 9 Mar 2026 19:33:28 +0100 Subject: [PATCH] feat: refactor play area into sidebar layout with buttplug, recordings, and leaderboard sub-pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /play sidebar layout (mobile nav + desktop sidebar) with SexyBackground - Move buttplug device control to /play/buttplug with Empty component and scan button - Move recordings from /me/recordings to /play/recordings - Move leaderboard to /play/leaderboard; redirect /leaderboard → /play/leaderboard - Redirect /me/recordings → /play/recordings and /play → /play/buttplug - Remove recordings entry from /me sidebar nav - Rename "SexyPlay" → "Play", swap bluetooth icon for rocket, remove subtitle - Add play.nav i18n keys (play, recordings, leaderboard, back_to_site, back_mobile) Co-Authored-By: Claude Sonnet 4.6 --- packages/frontend/src/lib/i18n/locales/en.ts | 12 +- .../src/routes/leaderboard/+page.server.ts | 67 +-- .../frontend/src/routes/me/+layout.svelte | 5 - .../src/routes/me/recordings/+page.server.ts | 8 +- .../src/routes/play/+layout.server.ts | 8 + .../frontend/src/routes/play/+layout.svelte | 108 ++++ .../frontend/src/routes/play/+page.server.ts | 21 +- .../src/routes/play/buttplug/+page.server.ts | 19 + .../routes/play/{ => buttplug}/+page.svelte | 475 ++++++++---------- .../routes/play/leaderboard/+page.server.ts | 60 +++ .../src/routes/play/leaderboard/+page.svelte | 188 +++++++ .../routes/play/recordings/+page.server.ts | 7 + .../src/routes/play/recordings/+page.svelte | 122 +++++ .../src/routes/users/[id]/+page.svelte | 2 +- 14 files changed, 738 insertions(+), 364 deletions(-) create mode 100644 packages/frontend/src/routes/play/+layout.server.ts create mode 100644 packages/frontend/src/routes/play/+layout.svelte create mode 100644 packages/frontend/src/routes/play/buttplug/+page.server.ts rename packages/frontend/src/routes/play/{ => buttplug}/+page.svelte (52%) create mode 100644 packages/frontend/src/routes/play/leaderboard/+page.server.ts create mode 100644 packages/frontend/src/routes/play/leaderboard/+page.svelte create mode 100644 packages/frontend/src/routes/play/recordings/+page.server.ts create mode 100644 packages/frontend/src/routes/play/recordings/+page.svelte diff --git a/packages/frontend/src/lib/i18n/locales/en.ts b/packages/frontend/src/lib/i18n/locales/en.ts index 5bc2904..f98f138 100644 --- a/packages/frontend/src/lib/i18n/locales/en.ts +++ b/packages/frontend/src/lib/i18n/locales/en.ts @@ -822,11 +822,19 @@ export default { questions_email: "support@pivoine.art", }, play: { - title: "SexyPlay", + title: "Play", description: "Bring your toys.", scan: "Start Scan", scanning: "Scanning...", - no_results: "No Devices founds", + no_results: "No devices found", + no_results_description: "Start a scan to discover nearby Bluetooth devices", + nav: { + play: "Play", + recordings: "Recordings", + leaderboard: "Leaderboard", + back_to_site: "Back to site", + back_mobile: "Site", + }, }, error: { not_found: "Oops! Page Not Found", diff --git a/packages/frontend/src/routes/leaderboard/+page.server.ts b/packages/frontend/src/routes/leaderboard/+page.server.ts index cf819c1..bf9358f 100644 --- a/packages/frontend/src/routes/leaderboard/+page.server.ts +++ b/packages/frontend/src/routes/leaderboard/+page.server.ts @@ -1,66 +1,5 @@ import { redirect } from "@sveltejs/kit"; -import type { PageServerLoad } from "./$types"; -import { gql } from "graphql-request"; -import { getGraphQLClient } from "$lib/api"; -const LEADERBOARD_QUERY = gql` - query Leaderboard($limit: Int, $offset: Int) { - leaderboard(limit: $limit, offset: $offset) { - user_id - display_name - avatar - total_weighted_points - total_raw_points - recordings_count - playbacks_count - achievements_count - rank - } - } -`; - -export const load: PageServerLoad = async ({ fetch, url, locals }) => { - // Guard: Redirect to login if not authenticated - if (!locals.authStatus.authenticated) { - throw redirect(302, "/login"); - } - - try { - const limit = parseInt(url.searchParams.get("limit") || "100"); - const offset = parseInt(url.searchParams.get("offset") || "0"); - - const client = getGraphQLClient(fetch); - const data = await client.request<{ - leaderboard: { - user_id: string; - display_name: string | null; - avatar: string | null; - total_weighted_points: number | null; - total_raw_points: number | null; - recordings_count: number | null; - playbacks_count: number | null; - achievements_count: number | null; - rank: number; - }[]; - }>(LEADERBOARD_QUERY, { limit, offset }); - - return { - leaderboard: data.leaderboard || [], - pagination: { - limit, - offset, - hasMore: data.leaderboard?.length === limit, - }, - }; - } catch (error) { - console.error("Leaderboard load error:", error); - return { - leaderboard: [], - pagination: { - limit: 100, - offset: 0, - hasMore: false, - }, - }; - } -}; +export function load() { + throw redirect(301, "/play/leaderboard"); +} diff --git a/packages/frontend/src/routes/me/+layout.svelte b/packages/frontend/src/routes/me/+layout.svelte index 98632e6..de83ef9 100644 --- a/packages/frontend/src/routes/me/+layout.svelte +++ b/packages/frontend/src/routes/me/+layout.svelte @@ -10,11 +10,6 @@ const navLinks = $derived([ { name: $_("me.nav.profile"), href: "/me/profile", icon: "icon-[ri--user-line]" }, { name: $_("me.nav.security"), href: "/me/security", icon: "icon-[ri--shield-keyhole-line]" }, - { - name: $_("me.nav.recordings"), - href: "/me/recordings", - icon: "icon-[ri--play-list-2-line]", - }, ...(data.isModel ? [ { diff --git a/packages/frontend/src/routes/me/recordings/+page.server.ts b/packages/frontend/src/routes/me/recordings/+page.server.ts index c87da3e..037d34b 100644 --- a/packages/frontend/src/routes/me/recordings/+page.server.ts +++ b/packages/frontend/src/routes/me/recordings/+page.server.ts @@ -1,7 +1,5 @@ -import { getRecordings } from "$lib/services"; +import { redirect } from "@sveltejs/kit"; -export async function load({ fetch }) { - return { - recordings: await getRecordings(fetch).catch(() => []), - }; +export function load() { + throw redirect(301, "/play/recordings"); } diff --git a/packages/frontend/src/routes/play/+layout.server.ts b/packages/frontend/src/routes/play/+layout.server.ts new file mode 100644 index 0000000..01f84b6 --- /dev/null +++ b/packages/frontend/src/routes/play/+layout.server.ts @@ -0,0 +1,8 @@ +import { redirect } from "@sveltejs/kit"; + +export async function load({ locals }) { + if (!locals.authStatus.authenticated) { + throw redirect(302, "/login"); + } + return { authStatus: locals.authStatus }; +} diff --git a/packages/frontend/src/routes/play/+layout.svelte b/packages/frontend/src/routes/play/+layout.svelte new file mode 100644 index 0000000..6abbb40 --- /dev/null +++ b/packages/frontend/src/routes/play/+layout.svelte @@ -0,0 +1,108 @@ + + +
+ + +
+ +
+
+ + + + + {#each navLinks as link (link.href)} + + + + + {/each} +
+
+ + +
+ + + + +
+ {@render children()} +
+
+
+
diff --git a/packages/frontend/src/routes/play/+page.server.ts b/packages/frontend/src/routes/play/+page.server.ts index 2ad78d5..2702c1b 100644 --- a/packages/frontend/src/routes/play/+page.server.ts +++ b/packages/frontend/src/routes/play/+page.server.ts @@ -1,20 +1,5 @@ -import { getRecording } from "$lib/services"; -import type { Recording } from "$lib/types"; +import { redirect } from "@sveltejs/kit"; -export async function load({ locals, url, fetch }) { - const recordingId = url.searchParams.get("recording"); - - let recording: Recording | null = null; - if (recordingId && locals.authStatus.authenticated) { - try { - recording = await getRecording(recordingId, fetch); - } catch (error) { - console.error("Failed to load recording:", error); - } - } - - return { - authStatus: locals.authStatus, - recording, - }; +export function load() { + throw redirect(302, "/play/buttplug"); } diff --git a/packages/frontend/src/routes/play/buttplug/+page.server.ts b/packages/frontend/src/routes/play/buttplug/+page.server.ts new file mode 100644 index 0000000..1c2f5ec --- /dev/null +++ b/packages/frontend/src/routes/play/buttplug/+page.server.ts @@ -0,0 +1,19 @@ +import { getRecording } from "$lib/services"; +import type { Recording } from "$lib/types"; + +export async function load({ url, fetch }) { + const recordingId = url.searchParams.get("recording"); + + let recording: Recording | null = null; + if (recordingId) { + try { + recording = await getRecording(recordingId, fetch); + } catch (error) { + console.error("Failed to load recording:", error); + } + } + + return { + recording, + }; +} diff --git a/packages/frontend/src/routes/play/+page.svelte b/packages/frontend/src/routes/play/buttplug/+page.svelte similarity index 52% rename from packages/frontend/src/routes/play/+page.svelte rename to packages/frontend/src/routes/play/buttplug/+page.svelte index 20bb266..3ca5a4d 100644 --- a/packages/frontend/src/routes/play/+page.svelte +++ b/packages/frontend/src/routes/play/buttplug/+page.svelte @@ -4,14 +4,13 @@ import type * as ButtplugTypes from "@sexy.pivoine.art/buttplug"; import Button from "$lib/components/ui/button/button.svelte"; import { onMount } from "svelte"; - import { goto } from "$app/navigation"; import DeviceCard from "$lib/components/device-card/device-card.svelte"; - import RecordingSaveDialog from "./components/recording-save-dialog.svelte"; - import DeviceMappingDialog from "./components/device-mapping-dialog.svelte"; + import RecordingSaveDialog from "../components/recording-save-dialog.svelte"; + import DeviceMappingDialog from "../components/device-mapping-dialog.svelte"; import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types"; import { toast } from "svelte-sonner"; import { createRecording } from "$lib/services"; - import SexyBackground from "$lib/components/background/background.svelte"; + import * as Empty from "$lib/components/ui/empty"; // Runtime buttplug values — loaded dynamically from the buttplug nginx container let client: ButtplugTypes.ButtplugClient; @@ -41,7 +40,6 @@ async function init() { const connector = new ButtplugWasmClientConnector(); - // await ButtplugWasmClientConnector.activateLogging("info"); await client.connect(connector); client.on("deviceadded", onDeviceAdded); client.on("deviceremoved", (dev: ButtplugTypes.ButtplugClientDevice) => { @@ -62,7 +60,6 @@ const device = convertDevice(dev); devices.push(device); - // Try to read battery level — access through the reactive array so Svelte detects the mutation const idx = devices.length - 1; if (device.hasBattery) { try { @@ -95,16 +92,13 @@ const outputType = actuator.outputType as typeof ButtplugTypes.OutputType; await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value)); - // Capture event if recording if (isRecording && recordingStartTime) { captureEvent(device, actuatorIdx, value); } } function startRecording() { - if (devices.length === 0) { - return; - } + if (devices.length === 0) return; isRecording = true; recordingStartTime = performance.now(); recordedEvents = []; @@ -131,7 +125,7 @@ device_name: device.name, actuator_index: actuatorIdx, actuator_type: actuator.outputType, - value: (value / actuator.maxSteps) * 100, // Normalize to 0-100 + value: (value / actuator.maxSteps) * 100, }); } @@ -166,7 +160,11 @@ }; } - async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) { + async function handleSaveRecording(saveData: { + title: string; + description: string; + tags: string[]; + }) { const deviceInfo: DeviceInfo[] = devices.map((d) => ({ name: d.name, index: d.info.index, @@ -175,12 +173,12 @@ try { await createRecording({ - title: data.title, - description: data.description, + title: saveData.title, + description: saveData.description, duration: Math.round(recordingDuration), events: recordedEvents, device_info: deviceInfo, - tags: data.tags, + tags: saveData.tags, status: "draft", }); @@ -188,9 +186,6 @@ showSaveDialog = false; recordedEvents = []; recordingDuration = 0; - - // Optionally navigate to dashboard - // goto("/me?tab=recordings"); } catch (error) { console.error("Failed to save recording:", error); toast.error("Failed to save recording. Please try again."); @@ -203,24 +198,19 @@ recordingDuration = 0; } - // Playback functions function startPlayback() { - if (!data.recording) { - return; - } + if (!data.recording) return; if (devices.length === 0) { toast.error("Please connect devices before playing recording"); return; } - // Check if we need to map devices if (deviceMappings.size === 0 && (data.recording.device_info?.length ?? 0) > 0) { showMappingDialog = true; return; } - // Start playback with existing mappings beginPlayback(); } @@ -250,8 +240,6 @@ } playbackProgress = 0; currentEventIndex = 0; - - // Stop all devices devices.forEach((device) => handleStop(device)); } @@ -265,7 +253,6 @@ function resumePlayback() { if (!data.recording) return; - isPlaying = true; playbackStartTime = performance.now() - playbackProgress; scheduleNextEvent(); @@ -286,12 +273,10 @@ const delay = event.timestamp - currentTime; if (delay <= 0) { - // Execute event immediately executeEvent(event); currentEventIndex++; scheduleNextEvent(); } else { - // Schedule event playbackTimeoutId = setTimeout(() => { executeEvent(event); currentEventIndex++; @@ -302,31 +287,25 @@ } function executeEvent(event: RecordedEvent) { - // Get mapped device const device = deviceMappings.get(event.device_name); if (!device) { console.warn(`No device mapping for: ${event.device_name}`); return; } - // Find matching actuator by type const actuator = device.actuators.find((a) => a.outputType === event.actuator_type); if (!actuator) { console.warn(`Actuator type ${event.actuator_type} not found on ${device.name}`); return; } - // Convert normalized value (0-100) back to device scale const deviceValue = Math.round((event.value / 100) * actuator.maxSteps); - - // Send command to device via feature const feature = device.info.features.get(actuator.featureIndex); if (feature) { const outputType = actuator.outputType as typeof ButtplugTypes.OutputType; feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue)); } - // Update UI actuator.value = deviceValue; } @@ -336,7 +315,6 @@ const targetTime = (percentage / 100) * data.recording.duration; playbackProgress = targetTime; - // Find the event index at this time const seekEvents = (data.recording.events ?? []) as RecordedEvent[]; currentEventIndex = seekEvents.findIndex((e) => e.timestamp >= targetTime); if (currentEventIndex === -1) { @@ -355,10 +333,6 @@ const { data } = $props(); onMount(async () => { - if (!data.authStatus.authenticated) { - goto("/login"); - return; - } // Concatenation prevents Rollup from statically resolving this URL at build time const buttplugUrl = "/buttplug/" + "dist/index.js"; const bp = await import(/* @vite-ignore */ buttplugUrl); @@ -373,240 +347,203 @@ -
- +
+ +
+

{$_("play.title")}

+
-
-
- -
-

+ {#if devices.length > 0 && !data.recording} +
+ {#if !isRecording} +

-

- {$_("play.description")} -

-
- - -
-
- - - {#if devices.length > 0 && !data.recording} - {#if !isRecording} - - {:else} - - {/if} - {/if} -
-
- - - {#if data.recording} -
-
-

- {data.recording.title} -

- {#if data.recording.description} -

- {data.recording.description} -

- {/if} -
- - -
-
- - {Math.floor(playbackProgress / 1000 / 60)}:{( - Math.floor(playbackProgress / 1000) % 60 - ) - .toString() - .padStart(2, "0")} - -
{ - const rect = e.currentTarget.getBoundingClientRect(); - const percentage = ((e.clientX - rect.left) / rect.width) * 100; - seek(percentage); - }} - onkeydown={(e) => { - if (e.key === "ArrowRight") - seek(((playbackProgress + 1) / data.recording.duration) * 100); - else if (e.key === "ArrowLeft") - seek(((playbackProgress - 1) / data.recording.duration) * 100); - }} - > -
-
- - {Math.floor(data.recording.duration / 1000 / 60)}:{( - Math.floor(data.recording.duration / 1000) % 60 - ) - .toString() - .padStart(2, "0")} - -
-
- - -
- - {#if !isPlaying} - - {:else} - - {/if} -
- - -
-
-

Events

-

{data.recording.events?.length ?? 0}

-
-
-

Devices

-

{data.recording.device_info?.length ?? 0}

-
-
-

Status

-

{data.recording.status}

-
-
-
- {/if} -
-
-
-
- {#if devices} - {#each devices as device (device.name)} - handleChange(device, scalarIndex, val)} - onStop={() => handleStop(device)} - /> - {/each} + + Start Recording + + {:else} + {/if}
+ {/if} - {#if devices?.length === 0} -
-

- {$_("play.no_results")} -

-
- {/if} -
- - - ({ - name: d.name, - index: d.info.index, - capabilities: d.actuators.map((a) => a.outputType), - }))} - duration={recordingDuration} - onSave={handleSaveRecording} - onCancel={handleCancelSave} - /> - - + {#if data.recording} - +
+
+

+ {data.recording.title} +

+ {#if data.recording.description} +

+ {data.recording.description} +

+ {/if} +
+ + +
+
+ + {Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60) + .toString() + .padStart(2, "0")} + +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const percentage = ((e.clientX - rect.left) / rect.width) * 100; + seek(percentage); + }} + onkeydown={(e) => { + if (e.key === "ArrowRight") + seek(((playbackProgress + 1) / data.recording.duration) * 100); + else if (e.key === "ArrowLeft") + seek(((playbackProgress - 1) / data.recording.duration) * 100); + }} + > +
+
+ + {Math.floor(data.recording.duration / 1000 / 60)}:{( + Math.floor(data.recording.duration / 1000) % 60 + ) + .toString() + .padStart(2, "0")} + +
+
+ + +
+ + {#if !isPlaying} + + {:else} + + {/if} +
+ + +
+
+

Events

+

{data.recording.events?.length ?? 0}

+
+
+

Devices

+

{data.recording.device_info?.length ?? 0}

+
+
+

Status

+

{data.recording.status}

+
+
+
+ {/if} + + + {#if devices.length > 0} +
+ {#each devices as device (device.name)} + handleChange(device, scalarIndex, val)} + onStop={() => handleStop(device)} + /> + {/each} +
+ {:else} + + + + + + {$_("play.no_results")} + {$_("play.no_results_description")} + + + + + {/if}
+ + + ({ + name: d.name, + index: d.info.index, + capabilities: d.actuators.map((a) => a.outputType), + }))} + duration={recordingDuration} + onSave={handleSaveRecording} + onCancel={handleCancelSave} +/> + + +{#if data.recording} + +{/if} diff --git a/packages/frontend/src/routes/play/leaderboard/+page.server.ts b/packages/frontend/src/routes/play/leaderboard/+page.server.ts new file mode 100644 index 0000000..acf1368 --- /dev/null +++ b/packages/frontend/src/routes/play/leaderboard/+page.server.ts @@ -0,0 +1,60 @@ +import type { PageServerLoad } from "./$types"; +import { gql } from "graphql-request"; +import { getGraphQLClient } from "$lib/api"; + +const LEADERBOARD_QUERY = gql` + query Leaderboard($limit: Int, $offset: Int) { + leaderboard(limit: $limit, offset: $offset) { + user_id + display_name + avatar + total_weighted_points + total_raw_points + recordings_count + playbacks_count + achievements_count + rank + } + } +`; + +export const load: PageServerLoad = async ({ fetch, url }) => { + try { + const limit = parseInt(url.searchParams.get("limit") || "100"); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + const client = getGraphQLClient(fetch); + const data = await client.request<{ + leaderboard: { + user_id: string; + display_name: string | null; + avatar: string | null; + total_weighted_points: number | null; + total_raw_points: number | null; + recordings_count: number | null; + playbacks_count: number | null; + achievements_count: number | null; + rank: number; + }[]; + }>(LEADERBOARD_QUERY, { limit, offset }); + + return { + leaderboard: data.leaderboard || [], + pagination: { + limit, + offset, + hasMore: data.leaderboard?.length === limit, + }, + }; + } catch (error) { + console.error("Leaderboard load error:", error); + return { + leaderboard: [], + pagination: { + limit: 100, + offset: 0, + hasMore: false, + }, + }; + } +}; diff --git a/packages/frontend/src/routes/play/leaderboard/+page.svelte b/packages/frontend/src/routes/play/leaderboard/+page.svelte new file mode 100644 index 0000000..d5a7eb4 --- /dev/null +++ b/packages/frontend/src/routes/play/leaderboard/+page.svelte @@ -0,0 +1,188 @@ + + + + +
+
+
+

{$_("gamification.leaderboard")}

+

{$_("gamification.leaderboard_subtitle")}

+
+
+ + + + + + {$_("gamification.top_players")} + + + + {#if data.leaderboard.length === 0} +
+ +

{$_("gamification.no_rankings_yet")}

+
+ {:else} + + + {#if data.pagination.hasMore} +
+ +
+ {/if} + {/if} +
+
+ + + + +

+ + {$_("gamification.how_it_works")} +

+

+ {$_("gamification.how_it_works_description")} +

+
+
+ +
+
{$_("gamification.earn_by_creating")}
+
{$_("gamification.earn_by_creating_desc")}
+
+
+
+ +
+
{$_("gamification.earn_by_playing")}
+
{$_("gamification.earn_by_playing_desc")}
+
+
+
+ +
+
{$_("gamification.stay_active")}
+
{$_("gamification.stay_active_desc")}
+
+
+
+
+
+
diff --git a/packages/frontend/src/routes/play/recordings/+page.server.ts b/packages/frontend/src/routes/play/recordings/+page.server.ts new file mode 100644 index 0000000..c87da3e --- /dev/null +++ b/packages/frontend/src/routes/play/recordings/+page.server.ts @@ -0,0 +1,7 @@ +import { getRecordings } from "$lib/services"; + +export async function load({ fetch }) { + return { + recordings: await getRecordings(fetch).catch(() => []), + }; +} diff --git a/packages/frontend/src/routes/play/recordings/+page.svelte b/packages/frontend/src/routes/play/recordings/+page.svelte new file mode 100644 index 0000000..a6b7534 --- /dev/null +++ b/packages/frontend/src/routes/play/recordings/+page.svelte @@ -0,0 +1,122 @@ + + + + +
+
+

{$_("me.recordings.title")}

+
+ + {#if recordings.length === 0} + + + + + + {$_("me.recordings.no_recordings")} + {$_("me.recordings.no_recordings_description")} + + + + + + {:else} +
+ {#each recordings as recording (recording.id)} + + {/each} +
+ {/if} +
+ + + + + {$_("me.recordings.delete_confirm")} + This cannot be undone. + + + + + + + diff --git a/packages/frontend/src/routes/users/[id]/+page.svelte b/packages/frontend/src/routes/users/[id]/+page.svelte index 182c6ad..3a05fe7 100644 --- a/packages/frontend/src/routes/users/[id]/+page.svelte +++ b/packages/frontend/src/routes/users/[id]/+page.svelte @@ -131,7 +131,7 @@ {$_("gamification.stats")} -