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:
2026-03-09 14:16:53 -04:00
parent 51c1900d8c
commit 3072f72958
5 changed files with 350 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import { listDevices } from "$lib/api/device.remote";
export async function load() {
const devices = await listDevices();
return {
devices
};
}

View 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>