Skip to content

Commit a9d4dbc

Browse files
authored
Feature/shocker menu (#170)
* vibe coded stuff * Use create dialog directly * random rfid * move to correct layout group * revert that * fix more issues * supress error
1 parent 2aa4619 commit a9d4dbc

5 files changed

Lines changed: 372 additions & 15 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script lang="ts" module>
2+
import { ShockerModelType } from '$lib/api/internal/v1';
3+
4+
export interface AddShockerData {
5+
name: string;
6+
rfId: number;
7+
device: string;
8+
model: ShockerModelType;
9+
}
10+
11+
export function defaultAddShockerData(): AddShockerData {
12+
return {
13+
name: '',
14+
rfId: Math.floor(Math.random() * 65535) + 1,
15+
device: '',
16+
model: ShockerModelType.CaiXianlin,
17+
};
18+
}
19+
</script>
20+
21+
<script lang="ts">
22+
import type { NewShocker } from '$lib/api/internal/v1';
23+
import TextInput from '$lib/components/input/TextInput.svelte';
24+
import Button from '$lib/components/ui/button/button.svelte';
25+
import * as Dialog from '$lib/components/ui/dialog';
26+
import * as Select from '$lib/components/ui/select';
27+
import { Field, FieldLabel } from '$lib/components/ui/field/index.js';
28+
import { Input } from '$lib/components/ui/input';
29+
import type { DialogContentProps } from '$lib/components/dialog-manager/types';
30+
import type { OwnHub } from '$lib/state/hubs-state.svelte';
31+
32+
interface Props extends DialogContentProps<NewShocker | undefined> {
33+
data: AddShockerData;
34+
hubs: [string, OwnHub][];
35+
}
36+
37+
// eslint-disable-next-line svelte/no-unused-props -- properties are used via the local $state copy
38+
let { data: initialData, hubs, resolve, close }: Props = $props();
39+
40+
// svelte-ignore state_referenced_locally -- intentionally captures initial value as own reactive copy
41+
let data: AddShockerData = $state(initialData);
42+
43+
const modelOptions = [
44+
{ value: ShockerModelType.CaiXianlin, label: 'CaiXianlin' },
45+
{ value: ShockerModelType.PetTrainer, label: 'PetTrainer' },
46+
{ value: ShockerModelType.Petrainer998Dr, label: 'Petrainer998DR' },
47+
];
48+
49+
let canSubmit = $derived(data.name.trim().length > 0 && data.rfId > 0 && data.device.length > 0);
50+
51+
function submit() {
52+
if (!canSubmit) return;
53+
resolve({
54+
name: data.name.trim(),
55+
rfId: data.rfId,
56+
device: data.device,
57+
model: data.model,
58+
});
59+
}
60+
</script>
61+
62+
<Dialog.Header>
63+
<Dialog.Title>Add Shocker</Dialog.Title>
64+
<Dialog.Description>Register a new shocker to one of your hubs.</Dialog.Description>
65+
</Dialog.Header>
66+
<div class="flex flex-col gap-4 py-2">
67+
<TextInput label="Name" placeholder="My Shocker" bind:value={data.name} />
68+
69+
<Field class="gap-2">
70+
<FieldLabel>RF ID</FieldLabel>
71+
<Input type="number" placeholder="12345" bind:value={data.rfId} min={1} />
72+
</Field>
73+
74+
<Field class="gap-2">
75+
<FieldLabel>Model</FieldLabel>
76+
<Select.Root type="single" name="model" bind:value={data.model}>
77+
<Select.Trigger>
78+
{modelOptions.find((o) => o.value === data.model)?.label ?? 'Select model'}
79+
</Select.Trigger>
80+
<Select.Content>
81+
<Select.Group>
82+
{#each modelOptions as option (option.value)}
83+
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
84+
{/each}
85+
</Select.Group>
86+
</Select.Content>
87+
</Select.Root>
88+
</Field>
89+
90+
<Field class="gap-2">
91+
<FieldLabel>Hub</FieldLabel>
92+
<Select.Root type="single" name="hub" bind:value={data.device}>
93+
<Select.Trigger>
94+
{hubs.find(([id]) => id === data.device)?.[1].name ?? 'Select hub'}
95+
</Select.Trigger>
96+
<Select.Content>
97+
<Select.Group>
98+
{#each hubs as [id, hub] (id)}
99+
<Select.Item value={id} label={hub.name}>{hub.name}</Select.Item>
100+
{/each}
101+
</Select.Group>
102+
</Select.Content>
103+
</Select.Root>
104+
</Field>
105+
</div>
106+
<Dialog.Footer>
107+
<Button variant="outline" onclick={() => close()}>Cancel</Button>
108+
<Button disabled={!canSubmit} onclick={submit}>Add Shocker</Button>
109+
</Dialog.Footer>

src/lib/components/ControlModules/impl/ShockerMenu.svelte

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,62 @@
11
<script lang="ts">
2-
import { Ellipsis } from '@lucide/svelte';
2+
import { Ellipsis, LoaderCircle, Pause, Pencil, Play, Trash2 } from '@lucide/svelte';
33
import { goto } from '$app/navigation';
4+
import { shockersV1Api } from '$lib/api';
45
import type { ShockerResponse } from '$lib/api/internal/v1';
6+
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
57
import { Button } from '$lib/components/ui/button';
68
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
9+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
10+
import { refreshOwnHubs } from '$lib/state/hubs-state.svelte';
711
import { resolve } from '$app/paths';
12+
import { toast } from 'svelte-sonner';
813
914
interface Props {
1015
shocker: ShockerResponse;
1116
}
1217
1318
let { shocker }: Props = $props();
1419
20+
let pauseLoading = $state(false);
21+
1522
function viewLogs() {
1623
goto(resolve(`/shockers/logs/${shocker.id}`));
1724
}
25+
26+
function editShocker() {
27+
goto(resolve(`/shockers/${shocker.id}/edit`));
28+
}
29+
30+
async function togglePause() {
31+
pauseLoading = true;
32+
try {
33+
const result = await shockersV1Api.shockerPauseShocker(shocker.id, {
34+
pause: !shocker.isPaused,
35+
});
36+
shocker.isPaused = result.data;
37+
toast.success(shocker.isPaused ? 'Shocker paused' : 'Shocker resumed');
38+
} catch (error) {
39+
handleApiError(error);
40+
} finally {
41+
pauseLoading = false;
42+
}
43+
}
44+
45+
async function deleteShocker() {
46+
const result = await dialog.confirm({
47+
title: 'Delete Shocker',
48+
desc: `Are you sure you want to delete "${shocker.name}"? This action cannot be undone.`,
49+
confirmButtonText: 'Delete',
50+
});
51+
if (!result.confirmed) return;
52+
try {
53+
await shockersV1Api.shockerRemoveShocker(shocker.id);
54+
toast.success(`Shocker "${shocker.name}" deleted`);
55+
await refreshOwnHubs();
56+
} catch (error) {
57+
handleApiError(error);
58+
}
59+
}
1860
</script>
1961

2062
<DropdownMenu.Root>
@@ -27,6 +69,25 @@
2769
{/snippet}
2870
</DropdownMenu.Trigger>
2971
<DropdownMenu.Content>
72+
<DropdownMenu.Item class="cursor-pointer" onclick={togglePause} disabled={pauseLoading}>
73+
{#if pauseLoading}
74+
<LoaderCircle class="size-4 animate-spin" />
75+
{:else if shocker.isPaused}
76+
<Play class="size-4" />
77+
{:else}
78+
<Pause class="size-4" />
79+
{/if}
80+
{shocker.isPaused ? 'Resume' : 'Pause'}
81+
</DropdownMenu.Item>
82+
<DropdownMenu.Item class="cursor-pointer" onclick={editShocker}>
83+
<Pencil class="size-4" />
84+
Edit
85+
</DropdownMenu.Item>
3086
<DropdownMenu.Item class="cursor-pointer" onclick={viewLogs}>View Logs</DropdownMenu.Item>
87+
<DropdownMenu.Separator />
88+
<DropdownMenu.Item class="cursor-pointer text-red-500" onclick={deleteShocker}>
89+
<Trash2 class="size-4" />
90+
Delete
91+
</DropdownMenu.Item>
3192
</DropdownMenu.Content>
3293
</DropdownMenu.Root>

src/routes/(app)/settings/sessions/+page.svelte

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import { toast } from 'svelte-sonner';
2323
import DataTableActions from './data-table-actions.svelte';
2424
25-
let loading = $state<boolean>(false);
2625
let data = $state<LoginSessionResponse[]>([]);
2726
let sorting = $state<SortingState>([]);
2827
@@ -48,18 +47,11 @@
4847
},
4948
];
5049
51-
function handleProblem(problem: ProblemDetails): boolean {
52-
return false;
53-
}
54-
5550
async function fetchSessions() {
56-
loading = true;
5751
try {
5852
data = await sessionsApi.sessionsListSessions();
5953
} catch (error) {
60-
await handleApiError(error, handleProblem);
61-
} finally {
62-
loading = false;
54+
await handleApiError(error);
6355
}
6456
}
6557
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script lang="ts">
2+
import { goto } from '$app/navigation';
3+
import { resolve } from '$app/paths';
4+
import { page } from '$app/state';
5+
import { shockersV1Api } from '$lib/api';
6+
import { ShockerModelType, type ShockerWithDevice } from '$lib/api/internal/v1';
7+
import Container from '$lib/components/Container.svelte';
8+
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
9+
import TextInput from '$lib/components/input/TextInput.svelte';
10+
import Button from '$lib/components/ui/button/button.svelte';
11+
import { Field, FieldLabel } from '$lib/components/ui/field/index.js';
12+
import { Input } from '$lib/components/ui/input';
13+
import * as Select from '$lib/components/ui/select';
14+
import PauseToggle from '$lib/components/utils/PauseToggle.svelte';
15+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
16+
import { refreshOwnHubs } from '$lib/state/hubs-state.svelte';
17+
import { LoaderCircle } from '@lucide/svelte';
18+
import { onMount } from 'svelte';
19+
import { toast } from 'svelte-sonner';
20+
21+
const modelOptions = [
22+
{ value: ShockerModelType.CaiXianlin, label: 'CaiXianlin' },
23+
{ value: ShockerModelType.PetTrainer, label: 'PetTrainer' },
24+
{ value: ShockerModelType.Petrainer998Dr, label: 'Petrainer998DR' },
25+
];
26+
27+
let shocker = $state<ShockerWithDevice | null>(null);
28+
let name = $state('');
29+
let rfId = $state(0);
30+
let model = $state<ShockerModelType>(ShockerModelType.CaiXianlin);
31+
let saving = $state(false);
32+
33+
onMount(async () => {
34+
try {
35+
const shockerId = page.params.shockerId!;
36+
const response = await shockersV1Api.shockerGetShockerById(shockerId);
37+
shocker = response.data;
38+
name = shocker.name;
39+
rfId = shocker.rfId;
40+
model = shocker.model;
41+
} catch (error) {
42+
handleApiError(error);
43+
goto(resolve('/shockers/own'));
44+
}
45+
});
46+
47+
async function save() {
48+
if (!shocker || !name.trim()) return;
49+
saving = true;
50+
try {
51+
await shockersV1Api.shockerEditShocker(shocker.id, {
52+
name: name.trim(),
53+
rfId,
54+
model,
55+
device: shocker.device,
56+
});
57+
toast.success('Shocker updated');
58+
await refreshOwnHubs();
59+
} catch (error) {
60+
handleApiError(error);
61+
} finally {
62+
saving = false;
63+
}
64+
}
65+
66+
async function deleteShocker() {
67+
if (!shocker) return;
68+
const result = await dialog.confirm({
69+
title: 'Delete Shocker',
70+
desc: `Are you sure you want to delete "${shocker.name}"? This action cannot be undone.`,
71+
confirmButtonText: 'Delete',
72+
});
73+
if (!result.confirmed) return;
74+
try {
75+
await shockersV1Api.shockerRemoveShocker(shocker.id);
76+
toast.success(`Shocker "${shocker.name}" deleted`);
77+
await refreshOwnHubs();
78+
goto(resolve('/shockers/own'));
79+
} catch (error) {
80+
handleApiError(error);
81+
}
82+
}
83+
</script>
84+
85+
<Container>
86+
{#if !shocker}
87+
<div class="flex items-center gap-2 p-8">
88+
<LoaderCircle class="animate-spin" />
89+
<span>Loading shocker...</span>
90+
</div>
91+
{:else}
92+
<div class="mx-auto flex w-full max-w-lg flex-col gap-6 py-4">
93+
<h1 class="text-2xl font-bold">Edit Shocker</h1>
94+
95+
<TextInput label="Name" placeholder="Shocker name" bind:value={name} />
96+
97+
<Field class="gap-2">
98+
<FieldLabel>RF ID</FieldLabel>
99+
<Input type="number" bind:value={rfId} min={1} />
100+
</Field>
101+
102+
<Field class="gap-2">
103+
<FieldLabel>Model</FieldLabel>
104+
<Select.Root type="single" name="model" bind:value={model}>
105+
<Select.Trigger>
106+
{modelOptions.find((o) => o.value === model)?.label ?? 'Select model'}
107+
</Select.Trigger>
108+
<Select.Content>
109+
<Select.Group>
110+
{#each modelOptions as option (option.value)}
111+
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
112+
{/each}
113+
</Select.Group>
114+
</Select.Content>
115+
</Select.Root>
116+
</Field>
117+
118+
<Field class="gap-2">
119+
<FieldLabel>Pause State</FieldLabel>
120+
<div class="flex items-center gap-2">
121+
<PauseToggle
122+
shockerId={shocker.id}
123+
bind:paused={shocker.isPaused}
124+
userShareUserId={undefined}
125+
onPausedChange={() => {}}
126+
/>
127+
<span class="text-muted-foreground text-sm">
128+
{shocker.isPaused ? 'Shocker is paused' : 'Shocker is active'}
129+
</span>
130+
</div>
131+
</Field>
132+
133+
<Button disabled={saving || !name.trim()} onclick={save}>
134+
{#if saving}<LoaderCircle class="animate-spin" />{/if}
135+
Save Changes
136+
</Button>
137+
138+
<hr class="border-destructive/30" />
139+
140+
<div class="flex flex-col gap-2">
141+
<h2 class="text-destructive text-lg font-semibold">Danger Zone</h2>
142+
<p class="text-muted-foreground text-sm">
143+
Permanently delete this shocker. This action cannot be undone.
144+
</p>
145+
<Button variant="destructive" onclick={deleteShocker}>Delete Shocker</Button>
146+
</div>
147+
</div>
148+
{/if}
149+
</Container>

0 commit comments

Comments
 (0)