Skip to content

Commit 21ec79d

Browse files
committed
Implement MVP frontend pages and shocker pause overlay
- Build authenticated home page (#116) with stats cards, quick links, and getting-started prompts for new users - Improve shockers page (#122) with refresh button, proper loading/empty states, responsive toolbar, breadcrumbs, and better error messages - Polish shocker edit page (#137) with card layout, back navigation, breadcrumbs, dirty state tracking, and error fallback UI - Add pause overlay to all control modules (Classic, Rich, Simple) that blurs controls and shows a clickable resume button with hover state closes #116 closes #122 closes #137
1 parent 77b5e2f commit 21ec79d

6 files changed

Lines changed: 482 additions & 102 deletions

File tree

src/lib/components/ControlModules/ClassicControlModule.svelte

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
ControlIntensityDefault,
77
ControlIntensityProps,
88
} from '$lib/constants/ControlConstants';
9+
import { LoaderCircle, Pause, Play } from '@lucide/svelte';
10+
import { shockersV1Api } from '$lib/api';
11+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
912
import { getConnection } from '$lib/signalr/user.svelte';
1013
import { ControlType } from '$lib/signalr/models/ControlType';
1114
import { serializeControlMessages } from '$lib/signalr/serializers/Control';
@@ -25,6 +28,20 @@
2528
let duration = $state(ControlDurationDefault);
2629
let active = $state<ControlType | null>(null);
2730
31+
let resuming = $state(false);
32+
33+
async function resume() {
34+
resuming = true;
35+
try {
36+
const result = await shockersV1Api.shockerPauseShocker(shocker.id, { pause: false });
37+
shocker.isPaused = result.data;
38+
} catch (error) {
39+
handleApiError(error);
40+
} finally {
41+
resuming = false;
42+
}
43+
}
44+
2845
function ctrl(type: ControlType) {
2946
const conn = getConnection();
3047
if (!conn) return;
@@ -35,8 +52,27 @@
3552
<ControlListener shockerId={shocker.id} bind:active />
3653

3754
<div
38-
class="border-surface-400-500-token flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
55+
class="border-surface-400-500-token relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
3956
>
57+
{#if shocker.isPaused}
58+
<button
59+
class="group absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md bg-black/60 backdrop-blur-sm transition-colors hover:bg-black/50"
60+
onclick={resume}
61+
disabled={resuming}
62+
>
63+
{#if resuming}
64+
<LoaderCircle class="size-8 animate-spin text-white" />
65+
{:else}
66+
<Pause class="size-8 text-white/60 group-hover:hidden" />
67+
<Play class="hidden size-8 text-white group-hover:block" />
68+
{/if}
69+
<span class="text-sm font-semibold text-white">
70+
{#if resuming}Resuming...{:else}<span class="group-hover:hidden">Paused</span><span
71+
class="hidden group-hover:inline">Resume</span
72+
>{/if}
73+
</span>
74+
</button>
75+
{/if}
4076
<!-- Title -->
4177
<h2 class="flex w-full justify-between px-4 text-center text-lg font-bold">
4278
<span>

src/lib/components/ControlModules/RichControlModule.svelte

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
2-
import { Signal, Timer } from '@lucide/svelte';
2+
import { LoaderCircle, Pause, Play, Signal, Timer } from '@lucide/svelte';
3+
import { shockersV1Api } from '$lib/api';
4+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
35
import type { ShockerResponse } from '$lib/api/internal/v1';
46
import {
57
ControlDurationDefault,
@@ -24,6 +26,20 @@
2426
let duration = $state(ControlDurationDefault);
2527
let active = $state<ControlType | null>(null);
2628
29+
let resuming = $state(false);
30+
31+
async function resume() {
32+
resuming = true;
33+
try {
34+
const result = await shockersV1Api.shockerPauseShocker(shocker.id, { pause: false });
35+
shocker.isPaused = result.data;
36+
} catch (error) {
37+
handleApiError(error);
38+
} finally {
39+
resuming = false;
40+
}
41+
}
42+
2743
function ctrl(type: ControlType) {
2844
const conn = getConnection();
2945
if (!conn) return;
@@ -34,8 +50,27 @@
3450
<ControlListener shockerId={shocker.id} bind:active />
3551

3652
<div
37-
class="border-surface-400-500-token flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
53+
class="border-surface-400-500-token relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
3854
>
55+
{#if shocker.isPaused}
56+
<button
57+
class="group absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md bg-black/60 backdrop-blur-sm transition-colors hover:bg-black/50"
58+
onclick={resume}
59+
disabled={resuming}
60+
>
61+
{#if resuming}
62+
<LoaderCircle class="size-8 animate-spin text-white" />
63+
{:else}
64+
<Pause class="size-8 text-white/60 group-hover:hidden" />
65+
<Play class="hidden size-8 text-white group-hover:block" />
66+
{/if}
67+
<span class="text-sm font-semibold text-white">
68+
{#if resuming}Resuming...{:else}<span class="group-hover:hidden">Paused</span><span
69+
class="hidden group-hover:inline">Resume</span
70+
>{/if}
71+
</span>
72+
</button>
73+
{/if}
3974
<!-- Title -->
4075
<h2 class="w-full truncate px-4 text-center text-lg font-bold">{shocker.name}</h2>
4176
<!-- Sliders -->

src/lib/components/ControlModules/SimpleControlModule.svelte

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<script lang="ts">
2+
import { LoaderCircle, Pause, Play } from '@lucide/svelte';
3+
import { shockersV1Api } from '$lib/api';
24
import type { ShockerResponse } from '$lib/api/internal/v1';
5+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
36
import { getConnection } from '$lib/signalr/user.svelte';
47
import { ControlType } from '$lib/signalr/models/ControlType';
58
import { serializeControlMessages } from '$lib/signalr/serializers/Control';
@@ -17,6 +20,19 @@
1720
let { shocker, shockIntensity, vibrationIntensity, duration, disabled }: Props = $props();
1821
1922
let active = $state<ControlType | null>(null);
23+
let resuming = $state(false);
24+
25+
async function resume() {
26+
resuming = true;
27+
try {
28+
const result = await shockersV1Api.shockerPauseShocker(shocker.id, { pause: false });
29+
shocker.isPaused = result.data;
30+
} catch (error) {
31+
handleApiError(error);
32+
} finally {
33+
resuming = false;
34+
}
35+
}
2036
2137
function ctrl(type: ControlType) {
2238
let intensity: number;
@@ -46,8 +62,27 @@
4662
<ControlListener shockerId={shocker.id} bind:active />
4763

4864
<div
49-
class="border-surface-400-500-token flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
65+
class="border-surface-400-500-token relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
5066
>
67+
{#if shocker.isPaused}
68+
<button
69+
class="group absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md bg-black/60 backdrop-blur-sm transition-colors hover:bg-black/50"
70+
onclick={resume}
71+
disabled={resuming}
72+
>
73+
{#if resuming}
74+
<LoaderCircle class="size-8 animate-spin text-white" />
75+
{:else}
76+
<Pause class="size-8 text-white/60 group-hover:hidden" />
77+
<Play class="hidden size-8 text-white group-hover:block" />
78+
{/if}
79+
<span class="text-sm font-semibold text-white">
80+
{#if resuming}Resuming...{:else}<span class="group-hover:hidden">Paused</span><span
81+
class="hidden group-hover:inline">Resume</span
82+
>{/if}
83+
</span>
84+
</button>
85+
{/if}
5186
<!-- Title -->
5287
<h2 class="w-full truncate px-4 text-center text-lg font-bold">{shocker.name}</h2>
5388
<!-- Buttons -->

src/routes/(app)/home/+page.svelte

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,164 @@
11
<script lang="ts">
2+
import { Link, Router, Share2, Zap } from '@lucide/svelte';
3+
import { resolve } from '$app/paths';
24
import Container from '$lib/components/Container.svelte';
5+
import * as Card from '$lib/components/ui/card';
6+
import { Button } from '$lib/components/ui/button';
37
import { breadcrumbs } from '$lib/state/breadcrumbs-state.svelte';
8+
import { userState } from '$lib/state/user-state.svelte';
9+
import { onlineHubs, ownHubs, refreshOwnHubs } from '$lib/state/hubs-state.svelte';
10+
import { onMount } from 'svelte';
411
512
breadcrumbs.push('Home', '/home');
13+
14+
let shockerCount = $derived(
15+
Array.from(ownHubs).reduce((sum, [, hub]) => sum + hub.shockers.length, 0)
16+
);
17+
let hubCount = $derived(ownHubs.size);
18+
let onlineHubCount = $derived(
19+
Array.from(onlineHubs).filter(([, state]) => state.isOnline).length
20+
);
21+
22+
onMount(refreshOwnHubs);
623
</script>
724

8-
<Container>Home</Container>
25+
<Container>
26+
<div class="flex w-full flex-col gap-6">
27+
<div>
28+
<h1 class="text-3xl font-bold">
29+
Welcome back{userState.self ? `, ${userState.self.name}` : ''}
30+
</h1>
31+
<p class="text-muted-foreground mt-1">Here's an overview of your OpenShock setup.</p>
32+
</div>
33+
34+
<!-- Stats cards -->
35+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
36+
<Card.Root>
37+
<Card.Header class="flex flex-row items-center justify-between pb-2">
38+
<Card.Title class="text-sm font-medium">Shockers</Card.Title>
39+
<Zap class="text-muted-foreground size-4" />
40+
</Card.Header>
41+
<Card.Content>
42+
<div class="text-2xl font-bold">{shockerCount}</div>
43+
</Card.Content>
44+
</Card.Root>
45+
<Card.Root>
46+
<Card.Header class="flex flex-row items-center justify-between pb-2">
47+
<Card.Title class="text-sm font-medium">Hubs</Card.Title>
48+
<Router class="text-muted-foreground size-4" />
49+
</Card.Header>
50+
<Card.Content>
51+
<div class="text-2xl font-bold">{hubCount}</div>
52+
<p class="text-muted-foreground text-xs">
53+
{onlineHubCount} online
54+
</p>
55+
</Card.Content>
56+
</Card.Root>
57+
<Card.Root>
58+
<Card.Header class="flex flex-row items-center justify-between pb-2">
59+
<Card.Title class="text-sm font-medium">Hub Status</Card.Title>
60+
<div
61+
class={onlineHubCount > 0
62+
? 'size-2 rounded-full bg-green-500'
63+
: 'size-2 rounded-full bg-red-500'}
64+
></div>
65+
</Card.Header>
66+
<Card.Content>
67+
<div class="text-2xl font-bold">
68+
{#if hubCount === 0}
69+
No hubs
70+
{:else if onlineHubCount === hubCount}
71+
All online
72+
{:else if onlineHubCount === 0}
73+
All offline
74+
{:else}
75+
{onlineHubCount}/{hubCount} online
76+
{/if}
77+
</div>
78+
</Card.Content>
79+
</Card.Root>
80+
</div>
81+
82+
<!-- Quick links -->
83+
<div>
84+
<h2 class="mb-3 text-lg font-semibold">Quick Links</h2>
85+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
86+
<Button
87+
variant="outline"
88+
class="h-auto justify-start gap-3 p-4"
89+
href={resolve('/shockers/own')}
90+
>
91+
<Zap class="size-5" />
92+
<div class="text-left">
93+
<div class="font-medium">My Shockers</div>
94+
<div class="text-muted-foreground text-xs">Control your shockers</div>
95+
</div>
96+
</Button>
97+
<Button variant="outline" class="h-auto justify-start gap-3 p-4" href={resolve('/hubs')}>
98+
<Router class="size-5" />
99+
<div class="text-left">
100+
<div class="font-medium">Hubs</div>
101+
<div class="text-muted-foreground text-xs">Manage your devices</div>
102+
</div>
103+
</Button>
104+
<Button
105+
variant="outline"
106+
class="h-auto justify-start gap-3 p-4"
107+
href={resolve('/shockers/shared')}
108+
>
109+
<Share2 class="size-5" />
110+
<div class="text-left">
111+
<div class="font-medium">Shared Shockers</div>
112+
<div class="text-muted-foreground text-xs">Shockers shared with you</div>
113+
</div>
114+
</Button>
115+
<Button
116+
variant="outline"
117+
class="h-auto justify-start gap-3 p-4"
118+
href={resolve('/shares/public')}
119+
>
120+
<Link class="size-5" />
121+
<div class="text-left">
122+
<div class="font-medium">Public Shares</div>
123+
<div class="text-muted-foreground text-xs">Manage share links</div>
124+
</div>
125+
</Button>
126+
</div>
127+
</div>
128+
129+
<!-- Getting started hint if no hubs/shockers -->
130+
{#if hubCount === 0}
131+
<Card.Root class="border-dashed">
132+
<Card.Header>
133+
<Card.Title>Get Started</Card.Title>
134+
<Card.Description>
135+
You don't have any hubs yet. Create a hub to connect your first device, then add
136+
shockers to it.
137+
</Card.Description>
138+
</Card.Header>
139+
<Card.Footer>
140+
<Button href={resolve('/hubs')}>
141+
<Router class="size-4" />
142+
Set Up a Hub
143+
</Button>
144+
</Card.Footer>
145+
</Card.Root>
146+
{:else if shockerCount === 0}
147+
<Card.Root class="border-dashed">
148+
<Card.Header>
149+
<Card.Title>Add Your First Shocker</Card.Title>
150+
<Card.Description>
151+
You have {hubCount} hub{hubCount > 1 ? 's' : ''} set up. Add a shocker to start controlling
152+
your devices.
153+
</Card.Description>
154+
</Card.Header>
155+
<Card.Footer>
156+
<Button href={resolve('/shockers/own')}>
157+
<Zap class="size-4" />
158+
Go to Shockers
159+
</Button>
160+
</Card.Footer>
161+
</Card.Root>
162+
{/if}
163+
</div>
164+
</Container>

0 commit comments

Comments
 (0)