feat: add device settings page
- Add device CRUD operations (create, delete, regenerate api keys) - Add API key visibility toggle and copy button
This commit is contained in:
43
frontend/src/lib/api/device.remote.ts
Normal file
43
frontend/src/lib/api/device.remote.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { command, form, getRequestEvent, query } from '$app/server';
|
||||||
|
import { createDeviceSchema, type Device } from '$lib/schema/device';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const listDevices = query(async (): Promise<Device[]> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.get(`/devices`);
|
||||||
|
|
||||||
|
if (!response.ok) error(500, 'An unkown error occurred');
|
||||||
|
|
||||||
|
const deviceResult = await response.json();
|
||||||
|
return deviceResult.items
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.post(`/devices`, data);
|
||||||
|
|
||||||
|
if (!response.ok) error(500, 'An unknown error occurred');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Promise<Device> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.get(`/devices/${deviceId}/regenerate`);
|
||||||
|
|
||||||
|
if (!response.ok) error(500, 'An unknown error occurred');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.delete(`/devices/${deviceId}`);
|
||||||
|
|
||||||
|
if (!response.ok) error(500, 'An unknown error occurred');
|
||||||
|
});
|
||||||
50
frontend/src/lib/components/forms/device-create-form.svelte
Normal file
50
frontend/src/lib/components/forms/device-create-form.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createDevice } from '$lib/api/device.remote';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSuccess }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
{...createDevice.enhance(async ({ form, submit }) => {
|
||||||
|
try {
|
||||||
|
await submit();
|
||||||
|
|
||||||
|
const issues = createDevice.fields.allIssues();
|
||||||
|
if (issues && issues.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceName = createDevice.fields.name.value();
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
toast.success(`Device '${deviceName}' created`);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create device: ', error);
|
||||||
|
toast.error('Failed to create device');
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<Field.Set>
|
||||||
|
<Field.Group>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="name">Device name</Field.Label>
|
||||||
|
<Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" />
|
||||||
|
{#each createDevice.fields.name.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Group>
|
||||||
|
</Field.Set>
|
||||||
|
|
||||||
|
<Button type="submit" class="ml-auto w-24">Create</Button>
|
||||||
|
</form>
|
||||||
8
frontend/src/lib/schema/device.ts
Normal file
8
frontend/src/lib/schema/device.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { components } from './openapi/schema';
|
||||||
|
export type Device = components['schemas']['KosyncDeviceRead']
|
||||||
|
|
||||||
|
export const createDeviceSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name cannot be empty')
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { listDevices } from "$lib/api/device.remote";
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
const devices = await listDevices();
|
||||||
|
|
||||||
|
return {
|
||||||
|
devices
|
||||||
|
};
|
||||||
|
}
|
||||||
240
frontend/src/routes/(root)/settings/devices/+page.svelte
Normal file
240
frontend/src/routes/(root)/settings/devices/+page.svelte
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import {
|
||||||
|
EllipsisVertical,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Smartphone
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import DeviceCreateForm from '$lib/components/forms/device-create-form.svelte';
|
||||||
|
import { deleteDevice, regenerateDeviceApiKey } from '$lib/api/device.remote';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import type { Device } from '$lib/schema/device';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let createDialogOpen = $state(false);
|
||||||
|
let visibleApiKeys = $state<Set<string>>(new Set());
|
||||||
|
let deleteConfirmDevice = $state<Device | null>(null);
|
||||||
|
let regenerateConfirmDevice = $state<Device | null>(null);
|
||||||
|
|
||||||
|
function toggleApiKeyVisibility(deviceId: string) {
|
||||||
|
if (visibleApiKeys.has(deviceId)) {
|
||||||
|
visibleApiKeys.delete(deviceId);
|
||||||
|
} else {
|
||||||
|
visibleApiKeys.add(deviceId);
|
||||||
|
}
|
||||||
|
visibleApiKeys = new Set(visibleApiKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskApiKey(apiKey: string): string {
|
||||||
|
return '•'.repeat(Math.min(apiKey.length, 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success('API key copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(device: Device) {
|
||||||
|
try {
|
||||||
|
await deleteDevice(String(device.id));
|
||||||
|
toast.success(`Device '${device.name}' deleted`);
|
||||||
|
deleteConfirmDevice = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete device');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate(device: Device) {
|
||||||
|
try {
|
||||||
|
await regenerateDeviceApiKey(String(device.id));
|
||||||
|
toast.success(`API key regenerated for '${device.name}'`);
|
||||||
|
regenerateConfirmDevice = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to regenerate API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeviceCreated() {
|
||||||
|
createDialogOpen = false;
|
||||||
|
invalidateAll();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Devices</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Manage your KOReader devices</p>
|
||||||
|
</div>
|
||||||
|
<Dialog.Root bind:open={createDialogOpen}>
|
||||||
|
<Dialog.Trigger class={buttonVariants({ variant: 'outline' })}>
|
||||||
|
<Plus />
|
||||||
|
Add Device
|
||||||
|
</Dialog.Trigger>
|
||||||
|
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Add a new device</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Create a device to sync your KOReader reading progress.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<DeviceCreateForm onSuccess={handleDeviceCreated} />
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.devices.length === 0}
|
||||||
|
<Empty.Root class="py-12">
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media variant="icon">
|
||||||
|
<Smartphone />
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>No devices</Empty.Title>
|
||||||
|
<Empty.Description>
|
||||||
|
Add a device to sync your KOReader reading progress.
|
||||||
|
</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
<Empty.Content>
|
||||||
|
<Button onclick={() => (createDialogOpen = true)}>
|
||||||
|
<Plus />
|
||||||
|
Add Device
|
||||||
|
</Button>
|
||||||
|
</Empty.Content>
|
||||||
|
</Empty.Root>
|
||||||
|
{:else}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="p-0">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="pl-4">Name</Table.Head>
|
||||||
|
<Table.Head>API Key</Table.Head>
|
||||||
|
<Table.Head class="w-16"></Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each data.devices as device}
|
||||||
|
{@const isVisible = visibleApiKeys.has(String(device.id))}
|
||||||
|
<Table.Row class="h-14">
|
||||||
|
<Table.Cell class="pl-4 font-medium">
|
||||||
|
{device.name}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="rounded bg-muted px-2 py-1 font-mono text-sm">
|
||||||
|
{isVisible ? device.api_key : maskApiKey(device.api_key)}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => toggleApiKeyVisibility(String(device.id))}
|
||||||
|
>
|
||||||
|
{#if isVisible}
|
||||||
|
<EyeOff class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
onclick={() => copyToClipboard(device.api_key)}
|
||||||
|
>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-center">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||||
|
<EllipsisVertical class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onclick={() => (regenerateConfirmDevice = device)}>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Regenerate API Key
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-destructive focus:text-destructive"
|
||||||
|
onclick={() => (deleteConfirmDevice = device)}
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<AlertDialog.Root open={deleteConfirmDevice !== null}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>Delete device?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
Are you sure you want to delete "{deleteConfirmDevice?.name}"? This action cannot be undone.
|
||||||
|
The device will no longer be able to sync reading progress.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel onclick={() => (deleteConfirmDevice = null)}>Cancel</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
class={buttonVariants({ variant: 'destructive' })}
|
||||||
|
onclick={() => deleteConfirmDevice && handleDelete(deleteConfirmDevice)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|
||||||
|
<!-- Regenerate API Key Confirmation Dialog -->
|
||||||
|
<AlertDialog.Root open={regenerateConfirmDevice !== null}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>Regenerate API key?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This will invalidate the current API key for "{regenerateConfirmDevice?.name}".
|
||||||
|
You will need to update the key in your KOReader device settings.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel onclick={() => (regenerateConfirmDevice = null)}>Cancel</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
onclick={() => regenerateConfirmDevice && handleRegenerate(regenerateConfirmDevice)}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
Reference in New Issue
Block a user