Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/lib/components/ControlModules/SharedShockerControlModule.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script lang="ts">
import { ChartNoAxesGantt, ClockFading, Gauge, Pause } from '@lucide/svelte';
import type { SharedShocker } from '$lib/api/internal/v1';
import {
ControlDurationDefault,
ControlDurationProps,
ControlIntensityDefault,
ControlIntensityProps,
} from '$lib/constants/ControlConstants';
import { SignalR_Connection } from '$lib/signalr';
import { ControlType } from '$lib/signalr/models/ControlType';
import { serializeControlMessages } from '$lib/signalr/serializers/Control';
import ControlListener from './ControlListener.svelte';
import ActionButtons from './impl/ActionButtons.svelte';
import CircleSlider from './impl/CircleSlider.svelte';

interface Props {
shocker: SharedShocker;
disabled?: boolean;
}

let { shocker, disabled }: Props = $props();

// Limits from API (duration in ms, convert to seconds for display)
const maxIntensity = $derived(shocker.limits.intensity ?? 100);
const maxDurationSeconds = $derived(
shocker.limits.duration ? shocker.limits.duration / 1000 : ControlDurationProps.max
);

// Pause state
const isPaused = $derived(shocker.isPaused);

// Permissions
const permissions = $derived(shocker.permissions);

let intensity = $state(ControlIntensityDefault);
let duration = $state(ControlDurationDefault);
let active = $state<ControlType | null>(null);

// Clamp values to limits
const clampedIntensity = $derived(Math.min(intensity, maxIntensity));
const clampedDuration = $derived(Math.min(duration, maxDurationSeconds));

function ctrl(type: ControlType) {
if (!$SignalR_Connection) return;
serializeControlMessages($SignalR_Connection, [
{
id: shocker.id,
type,
intensity: clampedIntensity,
duration: clampedDuration * 1000,
},
Comment on lines +48 to +52

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass duration in seconds to serializer

The shared control module multiplies duration by 1000 before calling serializeControlMessages, but that serializer already multiplies control.duration by 1000 (see src/lib/signalr/serializers/Control.ts). As a result, any control sent from the shared shockers page uses a duration 1000× longer than intended (seconds → ms in this component, then ms → μs in the serializer). Users will experience shock/vibrate/sound actions that last far longer than the slider indicates; this only occurs when triggering controls from the shared shockers page. Pass clampedDuration in seconds (like the other control modules) and let the serializer handle conversion.

Useful? React with 👍 / 👎.

]);
}

// Permission check for each control type
const disabledControls = $derived({
[ControlType.Sound]: !permissions.sound,
[ControlType.Vibrate]: !permissions.vibrate,
[ControlType.Shock]: !permissions.shock,
});
</script>

<ControlListener shockerId={shocker.id} bind:active />

<div
class="border-surface-400-500-token flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
class:opacity-50={isPaused}
>
<!-- Title -->
<h2 class="w-full truncate px-4 text-center text-lg font-bold">{shocker.name}</h2>

<div class="grow flex flex-col items-center justify-center">
<!-- Pause indicator -->
{#if isPaused}
<div class="flex items-center gap-1 text-sm text-destructive">
<Pause size={14} />
<span>Paused</span>
</div>
{/if}

<!-- Limits -->
<div class="flex gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1" title="Max Intensity">
<Gauge size={14} />
{maxIntensity}%
</span>
<span class="flex items-center gap-1" title="Max Duration">
<ClockFading size={14} />
{maxDurationSeconds}s
</span>
</div>
</div>

<!-- Sliders -->
<div class="flex items-center gap-2">
<CircleSlider
name="Intensity"
bind:value={intensity}
{...ControlIntensityProps}
max={maxIntensity}
/>
<CircleSlider
name="Duration"
bind:value={duration}
{...ControlDurationProps}
max={maxDurationSeconds}
/>
</div>
<!-- Buttons -->
<ActionButtons
{ctrl}
duration={clampedDuration}
{active}
disabled={disabled || isPaused}
{disabledControls}
/>
</div>
19 changes: 19 additions & 0 deletions src/lib/stores/SharedHubsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { shockersV1Api } from '$lib/api';
import type { OwnerShockerResponse } from '$lib/api/internal/v1';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { writable } from 'svelte/store';

export const SharedHubsStore = writable<OwnerShockerResponse[]>([]);

export async function refreshSharedHubs() {
try {
const response = await shockersV1Api.shockerListSharedShockers();
if (!response.data) {
throw new Error(`Failed to fetch shared devices: ${response.message}`);
}
SharedHubsStore.set(response.data);
} catch (error) {
handleApiError(error);
throw error;
}
}
11 changes: 11 additions & 0 deletions src/routes/(authenticated)/shockers/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';

export const GET: RequestHandler = ({ url }) => {
const redirectUrl = url.origin + '/shockers/own?' + url.searchParams.toString();
return new Response('Redirecting...', {
status: 308,
headers: {
Location: redirectUrl,
},
});
};
87 changes: 87 additions & 0 deletions src/routes/(authenticated)/shockers/shared/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script lang="ts">
import { Router, User, Wifi, WifiOff } from '@lucide/svelte';
import Container from '$lib/components/Container.svelte';
import SharedShockerControlModule from '$lib/components/ControlModules/SharedShockerControlModule.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { OnlineHubsStore } from '$lib/stores/HubsStore';
import { SharedHubsStore, refreshSharedHubs } from '$lib/stores/SharedHubsStore';
import { onMount } from 'svelte';

onMount(refreshSharedHubs);

const hasSharedShockers = $derived($SharedHubsStore.length > 0);
</script>

{#if $SharedHubsStore == null}
<p>Loading...</p>
{:else}
<Container>
<div class="w-full flex content-center justify-between">
<h1 class="text-2xl font-bold">Shared Shockers</h1>
</div>
<hr class="border-2" />

{#if !hasSharedShockers}
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<User size={48} class="mb-4 opacity-50" />
<p class="text-lg">No shockers have been shared with you yet.</p>
<p class="text-sm">When someone shares their shockers with you, they'll appear here.</p>
</div>
{:else}
<div class="flex flex-col gap-6 mt-4">
{#each $SharedHubsStore as owner (owner.id)}
<!-- Owner Section -->
<div class="border rounded-lg p-4">
<!-- Owner Header -->
<div class="flex items-center gap-3 mb-4">
<Avatar.Root class="h-10 w-10">
<Avatar.Image src={owner.image} alt={owner.name} />
<Avatar.Fallback>
<User size={20} />
</Avatar.Fallback>
</Avatar.Root>
<div>
<h2 class="text-xl font-semibold">{owner.name}</h2>
{@const totalShockers = owner.devices.reduce((acc, d) => acc + d.shockers.length, 0)}
<p class="text-sm text-muted-foreground">
{totalShockers} shocker{totalShockers !== 1 ? 's' : ''}
</p>
</div>
</div>

<!-- Devices/Hubs for this owner -->
<div class="flex flex-col gap-4">
{#each owner.devices as device (device.id)}
<!-- Device/Hub Header -->
<div class="bg-muted/30 rounded-md p-3">
<div class="flex items-center gap-2 mb-3">
<Router size={18} />
<span class="font-medium">{device.name}</span>
{#if $OnlineHubsStore.get(device.id)?.isOnline}
<span class="flex items-center gap-1 text-xs text-green-500">
<Wifi size={14} />
Online
</span>
{:else}
<span class="flex items-center gap-1 text-xs text-red-500">
<WifiOff size={14} />
Offline
</span>
{/if}
</div>

<!-- Shockers Grid -->
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each device.shockers as shocker (shocker.id)}
<SharedShockerControlModule {shocker} />
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Container>
{/if}
2 changes: 1 addition & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
</div>
{/if}
<Header />
<main class="flex-1 min-h-0 overflow-hidden">
<main class="flex-1 min-h-0 overflow-x-hidden">
{@render children?.()}
</main>
<Footer />
Expand Down
8 changes: 7 additions & 1 deletion src/routes/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
MonitorSmartphone,
Router,
Settings,
Share2,
SquareActivity,
Timer,
Users,
Expand Down Expand Up @@ -81,7 +82,12 @@
{
title: 'Shockers',
Icon: Zap,
href: '/shockers',
href: '/shockers/own',
},
{
title: 'Shared Shockers',
Icon: Share2,
href: '/shockers/shared',
},
{
title: 'Hubs',
Expand Down
Loading