Skip to content
Merged
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
109 changes: 109 additions & 0 deletions src/lib/components/ControlModules/dialogs/dialog-shocker-add.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script lang="ts" module>
import { ShockerModelType } from '$lib/api/internal/v1';

export interface AddShockerData {
name: string;
rfId: number;
device: string;
model: ShockerModelType;
}

export function defaultAddShockerData(): AddShockerData {
return {
name: '',
rfId: Math.floor(Math.random() * 65535) + 1,
device: '',
model: ShockerModelType.CaiXianlin,
};
}
</script>

<script lang="ts">
import type { NewShocker } from '$lib/api/internal/v1';
import TextInput from '$lib/components/input/TextInput.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Field, FieldLabel } from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input';
import type { DialogContentProps } from '$lib/components/dialog-manager/types';
import type { OwnHub } from '$lib/state/hubs-state.svelte';

interface Props extends DialogContentProps<NewShocker | undefined> {
data: AddShockerData;
hubs: [string, OwnHub][];
}

// eslint-disable-next-line svelte/no-unused-props -- properties are used via the local $state copy
let { data: initialData, hubs, resolve, close }: Props = $props();

// svelte-ignore state_referenced_locally -- intentionally captures initial value as own reactive copy
let data: AddShockerData = $state(initialData);

const modelOptions = [
{ value: ShockerModelType.CaiXianlin, label: 'CaiXianlin' },
{ value: ShockerModelType.PetTrainer, label: 'PetTrainer' },
{ value: ShockerModelType.Petrainer998Dr, label: 'Petrainer998DR' },
];

let canSubmit = $derived(data.name.trim().length > 0 && data.rfId > 0 && data.device.length > 0);

function submit() {
if (!canSubmit) return;
resolve({
name: data.name.trim(),
rfId: data.rfId,
device: data.device,
model: data.model,
});
}
</script>

<Dialog.Header>
<Dialog.Title>Add Shocker</Dialog.Title>
<Dialog.Description>Register a new shocker to one of your hubs.</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4 py-2">
<TextInput label="Name" placeholder="My Shocker" bind:value={data.name} />

<Field class="gap-2">
<FieldLabel>RF ID</FieldLabel>
<Input type="number" placeholder="12345" bind:value={data.rfId} min={1} />
</Field>

<Field class="gap-2">
<FieldLabel>Model</FieldLabel>
<Select.Root type="single" name="model" bind:value={data.model}>
<Select.Trigger>
{modelOptions.find((o) => o.value === data.model)?.label ?? 'Select model'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each modelOptions as option (option.value)}
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field>

<Field class="gap-2">
<FieldLabel>Hub</FieldLabel>
<Select.Root type="single" name="hub" bind:value={data.device}>
<Select.Trigger>
{hubs.find(([id]) => id === data.device)?.[1].name ?? 'Select hub'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each hubs as [id, hub] (id)}
<Select.Item value={id} label={hub.name}>{hub.name}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => close()}>Cancel</Button>
<Button disabled={!canSubmit} onclick={submit}>Add Shocker</Button>
</Dialog.Footer>
63 changes: 62 additions & 1 deletion src/lib/components/ControlModules/impl/ShockerMenu.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,62 @@
<script lang="ts">
import { Ellipsis } from '@lucide/svelte';
import { Ellipsis, LoaderCircle, Pause, Pencil, Play, Trash2 } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { shockersV1Api } from '$lib/api';
import type { ShockerResponse } from '$lib/api/internal/v1';
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { refreshOwnHubs } from '$lib/state/hubs-state.svelte';
import { resolve } from '$app/paths';
import { toast } from 'svelte-sonner';

interface Props {
shocker: ShockerResponse;
}

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

let pauseLoading = $state(false);

function viewLogs() {
goto(resolve(`/shockers/logs/${shocker.id}`));
}

function editShocker() {
goto(resolve(`/shockers/${shocker.id}/edit`));
}

async function togglePause() {
pauseLoading = true;
try {
const result = await shockersV1Api.shockerPauseShocker(shocker.id, {
pause: !shocker.isPaused,
});
shocker.isPaused = result.data;
toast.success(shocker.isPaused ? 'Shocker paused' : 'Shocker resumed');
} catch (error) {
handleApiError(error);
} finally {
pauseLoading = false;
}
}

async function deleteShocker() {
const result = await dialog.confirm({
title: 'Delete Shocker',
desc: `Are you sure you want to delete "${shocker.name}"? This action cannot be undone.`,
confirmButtonText: 'Delete',
});
if (!result.confirmed) return;
try {
await shockersV1Api.shockerRemoveShocker(shocker.id);
toast.success(`Shocker "${shocker.name}" deleted`);
await refreshOwnHubs();
} catch (error) {
handleApiError(error);
}
}
</script>

<DropdownMenu.Root>
Expand All @@ -27,6 +69,25 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item class="cursor-pointer" onclick={togglePause} disabled={pauseLoading}>
{#if pauseLoading}
<LoaderCircle class="size-4 animate-spin" />
{:else if shocker.isPaused}
<Play class="size-4" />
{:else}
<Pause class="size-4" />
{/if}
{shocker.isPaused ? 'Resume' : 'Pause'}
</DropdownMenu.Item>
<DropdownMenu.Item class="cursor-pointer" onclick={editShocker}>
<Pencil class="size-4" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item class="cursor-pointer" onclick={viewLogs}>View Logs</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="cursor-pointer text-red-500" onclick={deleteShocker}>
<Trash2 class="size-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
10 changes: 1 addition & 9 deletions src/routes/(app)/settings/sessions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import { toast } from 'svelte-sonner';
import DataTableActions from './data-table-actions.svelte';

let loading = $state<boolean>(false);
let data = $state<LoginSessionResponse[]>([]);
let sorting = $state<SortingState>([]);

Expand All @@ -48,18 +47,11 @@
},
];

function handleProblem(problem: ProblemDetails): boolean {
return false;
}

async function fetchSessions() {
loading = true;
try {
data = await sessionsApi.sessionsListSessions();
} catch (error) {
await handleApiError(error, handleProblem);
} finally {
loading = false;
await handleApiError(error);
}
}

Expand Down
149 changes: 149 additions & 0 deletions src/routes/(app)/shockers/[shockerId=guid]/edit/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { shockersV1Api } from '$lib/api';
import { ShockerModelType, type ShockerWithDevice } from '$lib/api/internal/v1';
import Container from '$lib/components/Container.svelte';
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
import TextInput from '$lib/components/input/TextInput.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Field, FieldLabel } from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import PauseToggle from '$lib/components/utils/PauseToggle.svelte';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { refreshOwnHubs } from '$lib/state/hubs-state.svelte';
import { LoaderCircle } from '@lucide/svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';

const modelOptions = [
{ value: ShockerModelType.CaiXianlin, label: 'CaiXianlin' },
{ value: ShockerModelType.PetTrainer, label: 'PetTrainer' },
{ value: ShockerModelType.Petrainer998Dr, label: 'Petrainer998DR' },
];

let shocker = $state<ShockerWithDevice | null>(null);
let name = $state('');
let rfId = $state(0);
let model = $state<ShockerModelType>(ShockerModelType.CaiXianlin);
let saving = $state(false);

onMount(async () => {
try {
const shockerId = page.params.shockerId!;
const response = await shockersV1Api.shockerGetShockerById(shockerId);
shocker = response.data;
name = shocker.name;
rfId = shocker.rfId;
model = shocker.model;
} catch (error) {
handleApiError(error);
goto(resolve('/shockers/own'));
}
});

async function save() {
if (!shocker || !name.trim()) return;
saving = true;
try {
await shockersV1Api.shockerEditShocker(shocker.id, {
name: name.trim(),
rfId,
model,
device: shocker.device,
});
toast.success('Shocker updated');
await refreshOwnHubs();
} catch (error) {
handleApiError(error);
} finally {
saving = false;
}
}

async function deleteShocker() {
if (!shocker) return;
const result = await dialog.confirm({
title: 'Delete Shocker',
desc: `Are you sure you want to delete "${shocker.name}"? This action cannot be undone.`,
confirmButtonText: 'Delete',
});
if (!result.confirmed) return;
try {
await shockersV1Api.shockerRemoveShocker(shocker.id);
toast.success(`Shocker "${shocker.name}" deleted`);
await refreshOwnHubs();
goto(resolve('/shockers/own'));
} catch (error) {
handleApiError(error);
}
}
</script>

<Container>
{#if !shocker}
<div class="flex items-center gap-2 p-8">
<LoaderCircle class="animate-spin" />
<span>Loading shocker...</span>
</div>
{:else}
<div class="mx-auto flex w-full max-w-lg flex-col gap-6 py-4">
<h1 class="text-2xl font-bold">Edit Shocker</h1>

<TextInput label="Name" placeholder="Shocker name" bind:value={name} />

<Field class="gap-2">
<FieldLabel>RF ID</FieldLabel>
<Input type="number" bind:value={rfId} min={1} />
</Field>

<Field class="gap-2">
<FieldLabel>Model</FieldLabel>
<Select.Root type="single" name="model" bind:value={model}>
<Select.Trigger>
{modelOptions.find((o) => o.value === model)?.label ?? 'Select model'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each modelOptions as option (option.value)}
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field>

<Field class="gap-2">
<FieldLabel>Pause State</FieldLabel>
<div class="flex items-center gap-2">
<PauseToggle
shockerId={shocker.id}
bind:paused={shocker.isPaused}
userShareUserId={undefined}
onPausedChange={() => {}}
/>
<span class="text-muted-foreground text-sm">
{shocker.isPaused ? 'Shocker is paused' : 'Shocker is active'}
</span>
</div>
</Field>

<Button disabled={saving || !name.trim()} onclick={save}>
{#if saving}<LoaderCircle class="animate-spin" />{/if}
Save Changes
</Button>

<hr class="border-destructive/30" />

<div class="flex flex-col gap-2">
<h2 class="text-destructive text-lg font-semibold">Danger Zone</h2>
<p class="text-muted-foreground text-sm">
Permanently delete this shocker. This action cannot be undone.
</p>
<Button variant="destructive" onclick={deleteShocker}>Delete Shocker</Button>
</div>
</div>
{/if}
</Container>
Loading
Loading