feat(webui): add shortcuts management tab to config page with crud operations
- commit
- 7a7ce5743b66888cb04a13d56a3eb0ee8e95525b
- author
- bnema · · 2025-12-21 13:53 UTC
- parent
- 42bc673
- signature
- unsigned
1 file · +233 -286
M webui/src/pages/ConfigPage.svelte +233 -286
| @@ -1,6 +1,13 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { onMount } from "svelte"; | |
| 3 | 3 | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 4 | + | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |
| 5 | + | import { Button } from "$lib/components/ui/button"; | |
| 6 | + | import { Input } from "$lib/components/ui/input"; | |
| 7 | + | import { Label } from "$lib/components/ui/label"; | |
| 8 | + | import { Spinner } from "$lib/components/ui/spinner"; | |
| 9 | + | import * as Card from "$lib/components/ui/card"; | |
| 10 | + | import * as Tabs from "$lib/components/ui/tabs"; | |
| 4 | 11 | ||
| 5 | 12 | type ColorPalette = { | |
| 6 | 13 | background: string; | |
| @@ -12,6 +19,11 @@ | |||
| 12 | 19 | border: string; | |
| 13 | 20 | }; | |
| 14 | 21 | ||
| 22 | + | type SearchShortcut = { | |
| 23 | + | url: string; | |
| 24 | + | description: string; | |
| 25 | + | }; | |
| 26 | + | ||
| 15 | 27 | type ConfigDTO = { | |
| 16 | 28 | appearance: { | |
| 17 | 29 | sans_font: string; | |
| @@ -24,6 +36,7 @@ | |||
| 24 | 36 | }; | |
| 25 | 37 | default_ui_scale: number; | |
| 26 | 38 | default_search_engine: string; | |
| 39 | + | search_shortcuts: Record<string, SearchShortcut>; | |
| 27 | 40 | }; | |
| 28 | 41 | ||
| 29 | 42 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | |
| @@ -40,30 +53,39 @@ | |||
| 40 | 53 | return /^#[0-9a-fA-F]{6}$/.test(value); | |
| 41 | 54 | } | |
| 42 | 55 | ||
| 43 | - | ||
| 44 | - | let config = $state<ConfigDTO | null>(null); | |
| 56 | + | // UI state | |
| 45 | 57 | let loading = $state(true); | |
| 46 | - | let error = $state<string | null>(null); | |
| 58 | + | let loadError = $state<string | null>(null); | |
| 59 | + | let config = $state<ConfigDTO | null>(null); | |
| 47 | 60 | let saving = $state(false); | |
| 48 | 61 | let saveSuccess = $state(false); | |
| 62 | + | let saveError = $state<string | null>(null); | |
| 49 | 63 | let themeEvents = $state(0); | |
| 64 | + | let activeTab = $state("appearance"); | |
| 50 | 65 | ||
| 51 | - | async function fetchConfig() { | |
| 66 | + | // Load config from API | |
| 67 | + | async function loadConfig() { | |
| 68 | + | loading = true; | |
| 69 | + | loadError = null; | |
| 52 | 70 | try { | |
| 53 | 71 | const response = await fetch("/api/config"); | |
| 54 | 72 | if (!response.ok) throw new Error("Failed to fetch config"); | |
| 55 | 73 | config = (await response.json()) as ConfigDTO; | |
| 56 | 74 | } catch (e: any) { | |
| 57 | - | error = e.message; | |
| 75 | + | loadError = e.message; | |
| 76 | + | console.error("[config] load failed", e); | |
| 58 | 77 | } finally { | |
| 59 | 78 | loading = false; | |
| 60 | 79 | } | |
| 61 | 80 | } | |
| 62 | 81 | ||
| 82 | + | // Reload config (used after save to get normalized values) | |
| 83 | + | function reloadConfig() { | |
| 84 | + | loadConfig(); | |
| 85 | + | } | |
| 86 | + | ||
| 63 | 87 | async function resetToDefaults() { | |
| 64 | - | error = null; | |
| 65 | 88 | saveSuccess = false; | |
| 66 | - | ||
| 67 | 89 | const confirmed = window.confirm( | |
| 68 | 90 | "Reset this page to default settings? (You still need to click Save to persist.)", | |
| 69 | 91 | ); | |
| @@ -74,7 +96,7 @@ | |||
| 74 | 96 | if (!response.ok) throw new Error("Failed to fetch default config"); | |
| 75 | 97 | config = (await response.json()) as ConfigDTO; | |
| 76 | 98 | } catch (e: any) { | |
| 77 | - | error = e.message; | |
| 99 | + | console.error("[config] reset defaults failed", e); | |
| 78 | 100 | } | |
| 79 | 101 | } | |
| 80 | 102 | ||
| @@ -93,7 +115,7 @@ | |||
| 93 | 115 | async function saveConfig() { | |
| 94 | 116 | saving = true; | |
| 95 | 117 | saveSuccess = false; | |
| 96 | - | error = null; | |
| 118 | + | saveError = null; | |
| 97 | 119 | ||
| 98 | 120 | try { | |
| 99 | 121 | const bridge = getWebKitBridge(); | |
| @@ -118,7 +140,7 @@ | |||
| 118 | 140 | let timeout: number | null = window.setTimeout(() => { | |
| 119 | 141 | if (saving) { | |
| 120 | 142 | console.warn("[config] save timed out (no callback)"); | |
| 121 | - | error = "Save timed out (no response from native handler)"; | |
| 143 | + | saveError = "Save timed out (no response from native handler)"; | |
| 122 | 144 | saving = false; | |
| 123 | 145 | } | |
| 124 | 146 | }, 8000); | |
| @@ -136,8 +158,8 @@ | |||
| 136 | 158 | saveSuccess = true; | |
| 137 | 159 | saving = false; | |
| 138 | 160 | ||
| 139 | - | // Refresh config state from backend (in case watcher normalized values) | |
| 140 | - | fetchConfig(); | |
| 161 | + | // Refresh config from backend (in case watcher normalized values) | |
| 162 | + | reloadConfig(); | |
| 141 | 163 | ||
| 142 | 164 | setTimeout(() => { | |
| 143 | 165 | saveSuccess = false; | |
| @@ -146,7 +168,7 @@ | |||
| 146 | 168 | (window as any).__dumber_config_error = (msg: unknown) => { | |
| 147 | 169 | clearSaveTimeout(); | |
| 148 | 170 | console.error("[config] save error", msg); | |
| 149 | - | error = typeof msg === "string" ? msg : "Failed to save config"; | |
| 171 | + | saveError = typeof msg === "string" ? msg : "Failed to save config"; | |
| 150 | 172 | saving = false; | |
| 151 | 173 | }; | |
| 152 | 174 | ||
| @@ -165,11 +187,16 @@ | |||
| 165 | 187 | } | |
| 166 | 188 | } catch (e: any) { | |
| 167 | 189 | console.error("[config] save exception", e); | |
| 168 | - | error = e.message; | |
| 190 | + | saveError = e.message; | |
| 169 | 191 | saving = false; | |
| 170 | 192 | } | |
| 171 | 193 | } | |
| 172 | 194 | ||
| 195 | + | function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) { | |
| 196 | + | if (!config) return; | |
| 197 | + | config.search_shortcuts = nextShortcuts; | |
| 198 | + | } | |
| 199 | + | ||
| 173 | 200 | onMount(() => { | |
| 174 | 201 | console.debug("[config] mount", { | |
| 175 | 202 | webviewId: (window as any).__dumber_webview_id, | |
| @@ -180,300 +207,220 @@ | |||
| 180 | 207 | themeEvents += 1; | |
| 181 | 208 | console.debug("[config] theme changed", (e as CustomEvent).detail); | |
| 182 | 209 | }; | |
| 183 | - | window.addEventListener('dumber:theme-changed', onThemeChanged); | |
| 210 | + | window.addEventListener("dumber:theme-changed", onThemeChanged); | |
| 184 | 211 | ||
| 185 | - | fetchConfig(); | |
| 212 | + | // Load config on mount | |
| 213 | + | loadConfig(); | |
| 186 | 214 | ||
| 187 | 215 | return () => { | |
| 188 | - | window.removeEventListener('dumber:theme-changed', onThemeChanged); | |
| 216 | + | window.removeEventListener("dumber:theme-changed", onThemeChanged); | |
| 189 | 217 | }; | |
| 190 | 218 | }); | |
| 191 | 219 | </script> | |
| 192 | 220 | ||
| 193 | 221 | <ConfigShell> | |
| 194 | - | <div class="config-content"> | |
| 222 | + | <div class="flex w-full flex-col gap-6 px-6 py-8"> | |
| 195 | 223 | {#if loading} | |
| 196 | - | <div class="loading">LOADING…</div> | |
| 197 | - | {:else if error} | |
| 198 | - | <div class="error"> | |
| 199 | - | <strong>ERROR</strong> | |
| 200 | - | <span>{error}</span> | |
| 224 | + | <!-- Loading state with spinner --> | |
| 225 | + | <div class="flex flex-col items-center justify-center gap-4 py-16"> | |
| 226 | + | <Spinner class="size-8" /> | |
| 227 | + | <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground"> | |
| 228 | + | Loading configuration… | |
| 229 | + | </div> | |
| 201 | 230 | </div> | |
| 202 | - | {/if} | |
| 203 | - | ||
| 204 | - | {#if config} | |
| 205 | - | <div class="stack"> | |
| 206 | - | <!-- Appearance Section --> | |
| 207 | - | <section class="border border-border bg-card p-6 text-card-foreground"> | |
| 208 | - | <h2 class="mb-6 text-xl font-semibold">Appearance</h2> | |
| 209 | - | ||
| 210 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 211 | - | <div class="space-y-2"> | |
| 212 | - | <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label> | |
| 213 | - | <input | |
| 214 | - | id="sans_font" | |
| 215 | - | type="text" | |
| 216 | - | bind:value={config.appearance.sans_font} | |
| 217 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 218 | - | /> | |
| 219 | - | </div> | |
| 220 | - | ||
| 221 | - | <div class="space-y-2"> | |
| 222 | - | <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label> | |
| 223 | - | <input | |
| 224 | - | id="serif_font" | |
| 225 | - | type="text" | |
| 226 | - | bind:value={config.appearance.serif_font} | |
| 227 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 228 | - | /> | |
| 229 | - | </div> | |
| 230 | - | ||
| 231 | - | <div class="space-y-2"> | |
| 232 | - | <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label> | |
| 233 | - | <input | |
| 234 | - | id="monospace_font" | |
| 235 | - | type="text" | |
| 236 | - | bind:value={config.appearance.monospace_font} | |
| 237 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 238 | - | /> | |
| 239 | - | </div> | |
| 240 | - | ||
| 241 | - | <div class="space-y-2"> | |
| 242 | - | <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label> | |
| 243 | - | <input | |
| 244 | - | id="font_size" | |
| 245 | - | type="number" | |
| 246 | - | bind:value={config.appearance.default_font_size} | |
| 247 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 248 | - | /> | |
| 249 | - | </div> | |
| 250 | - | ||
| 251 | - | <div class="space-y-2"> | |
| 252 | - | <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label> | |
| 253 | - | <select | |
| 254 | - | id="color_scheme" | |
| 255 | - | bind:value={config.appearance.color_scheme} | |
| 256 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 257 | - | > | |
| 258 | - | <option value="default">Follow System</option> | |
| 259 | - | <option value="prefer-dark">Always Dark</option> | |
| 260 | - | <option value="prefer-light">Always Light</option> | |
| 261 | - | </select> | |
| 262 | - | </div> | |
| 263 | - | ||
| 231 | + | {:else if loadError} | |
| 232 | + | <!-- Error state --> | |
| 233 | + | <div class="flex flex-col items-center justify-center gap-4 py-16"> | |
| 234 | + | <Card.Root class="max-w-md"> | |
| 235 | + | <Card.Header> | |
| 236 | + | <Card.Title class="text-destructive">Failed to load config</Card.Title> | |
| 237 | + | <Card.Description>{loadError}</Card.Description> | |
| 238 | + | </Card.Header> | |
| 239 | + | <Card.Content> | |
| 240 | + | <Button variant="outline" onclick={reloadConfig}>Try again</Button> | |
| 241 | + | </Card.Content> | |
| 242 | + | </Card.Root> | |
| 243 | + | </div> | |
| 244 | + | {:else if config} | |
| 245 | + | <Tabs.Root bind:value={activeTab} class="w-full"> | |
| 246 | + | <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> | |
| 264 | 247 | <div class="space-y-2"> | |
| 265 | - | <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label> | |
| 266 | - | <input | |
| 267 | - | id="ui_scale" | |
| 268 | - | type="number" | |
| 269 | - | step="0.1" | |
| 270 | - | bind:value={config.default_ui_scale} | |
| 271 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 272 | - | /> | |
| 248 | + | <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground"> | |
| 249 | + | Config Workspace | |
| 250 | + | </div> | |
| 251 | + | <div class="text-2xl font-semibold text-foreground">dumb://config</div> | |
| 273 | 252 | </div> | |
| 253 | + | <Tabs.List class="bg-muted/60"> | |
| 254 | + | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |
| 255 | + | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |
| 256 | + | <Tabs.Trigger value="shortcuts">Shortcuts</Tabs.Trigger> | |
| 257 | + | </Tabs.List> | |
| 274 | 258 | </div> | |
| 275 | 259 | ||
| 276 | - | <div class="mt-8 space-y-8"> | |
| 277 | - | <div> | |
| 278 | - | <h3 class="mb-3 text-base font-semibold">Theme: Light</h3> | |
| 279 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 280 | - | {#each PALETTE_FIELDS as field (field.key)} | |
| 260 | + | <Tabs.Content value="appearance"> | |
| 261 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 262 | + | <Card.Header> | |
| 263 | + | <Card.Title>Appearance</Card.Title> | |
| 264 | + | <Card.Description>Fonts, palette, and theme preferences.</Card.Description> | |
| 265 | + | </Card.Header> | |
| 266 | + | <Card.Content class="space-y-8"> | |
| 267 | + | <div class="grid gap-6 md:grid-cols-2"> | |
| 268 | + | <div class="space-y-2"> | |
| 269 | + | <Label for="sans_font">Sans-Serif Font</Label> | |
| 270 | + | <Input id="sans_font" bind:value={config.appearance.sans_font} /> | |
| 271 | + | </div> | |
| 281 | 272 | <div class="space-y-2"> | |
| 282 | - | <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`} | |
| 283 | - | >{field.label}</label | |
| 273 | + | <Label for="serif_font">Serif Font</Label> | |
| 274 | + | <Input id="serif_font" bind:value={config.appearance.serif_font} /> | |
| 275 | + | </div> | |
| 276 | + | <div class="space-y-2"> | |
| 277 | + | <Label for="monospace_font">Monospace Font</Label> | |
| 278 | + | <Input id="monospace_font" bind:value={config.appearance.monospace_font} /> | |
| 279 | + | </div> | |
| 280 | + | <div class="space-y-2"> | |
| 281 | + | <Label for="font_size">Default Font Size</Label> | |
| 282 | + | <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} /> | |
| 283 | + | </div> | |
| 284 | + | <div class="space-y-2"> | |
| 285 | + | <Label for="color_scheme">Color Scheme</Label> | |
| 286 | + | <select | |
| 287 | + | id="color_scheme" | |
| 288 | + | bind:value={config.appearance.color_scheme} | |
| 289 | + | class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |
| 284 | 290 | > | |
| 285 | - | <div class="flex items-center gap-3"> | |
| 286 | - | <input | |
| 287 | - | id={`light_${field.key}`} | |
| 288 | - | type="color" | |
| 289 | - | value={config.appearance.light_palette[field.key]} | |
| 290 | - | oninput={(e) => { | |
| 291 | - | const v = (e.currentTarget as HTMLInputElement).value; | |
| 292 | - | config!.appearance.light_palette[field.key] = v; | |
| 293 | - | }} | |
| 294 | - | class="h-10 w-12 border border-input bg-background" | |
| 295 | - | /> | |
| 296 | - | <input | |
| 297 | - | type="text" | |
| 298 | - | bind:value={config.appearance.light_palette[field.key]} | |
| 299 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 300 | - | /> | |
| 291 | + | <option value="default">Follow System</option> | |
| 292 | + | <option value="prefer-dark">Always Dark</option> | |
| 293 | + | <option value="prefer-light">Always Light</option> | |
| 294 | + | </select> | |
| 295 | + | </div> | |
| 296 | + | <div class="space-y-2"> | |
| 297 | + | <Label for="ui_scale">UI Scale</Label> | |
| 298 | + | <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} /> | |
| 299 | + | </div> | |
| 300 | + | </div> | |
| 301 | + | ||
| 302 | + | <div class="space-y-6"> | |
| 303 | + | <div class="space-y-3"> | |
| 304 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |
| 305 | + | Theme: Light | |
| 306 | + | </div> | |
| 307 | + | <div class="grid gap-4 md:grid-cols-2"> | |
| 308 | + | {#each PALETTE_FIELDS as field (field.key)} | |
| 309 | + | <div class="space-y-2"> | |
| 310 | + | <Label for={`light_${field.key}`}>{field.label}</Label> | |
| 311 | + | <div class="flex items-center gap-3"> | |
| 312 | + | <input | |
| 313 | + | id={`light_${field.key}`} | |
| 314 | + | type="color" | |
| 315 | + | value={config.appearance.light_palette[field.key]} | |
| 316 | + | oninput={(e) => { | |
| 317 | + | const v = (e.currentTarget as HTMLInputElement).value; | |
| 318 | + | config!.appearance.light_palette[field.key] = v; | |
| 319 | + | }} | |
| 320 | + | class="h-10 w-12 border border-border bg-background" | |
| 321 | + | /> | |
| 322 | + | <Input bind:value={config.appearance.light_palette[field.key]} /> | |
| 323 | + | </div> | |
| 324 | + | {#if !isHexColor(config.appearance.light_palette[field.key])} | |
| 325 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 326 | + | {/if} | |
| 327 | + | </div> | |
| 328 | + | {/each} | |
| 301 | 329 | </div> | |
| 302 | - | {#if !isHexColor(config.appearance.light_palette[field.key])} | |
| 303 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 304 | - | {/if} | |
| 305 | 330 | </div> | |
| 306 | - | {/each} | |
| 307 | - | </div> | |
| 308 | - | </div> | |
| 309 | 331 | ||
| 310 | - | <div> | |
| 311 | - | <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3> | |
| 312 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 313 | - | {#each PALETTE_FIELDS as field (field.key)} | |
| 314 | - | <div class="space-y-2"> | |
| 315 | - | <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`} | |
| 316 | - | >{field.label}</label | |
| 317 | - | > | |
| 318 | - | <div class="flex items-center gap-3"> | |
| 319 | - | <input | |
| 320 | - | id={`dark_${field.key}`} | |
| 321 | - | type="color" | |
| 322 | - | value={config.appearance.dark_palette[field.key]} | |
| 323 | - | oninput={(e) => { | |
| 324 | - | const v = (e.currentTarget as HTMLInputElement).value; | |
| 325 | - | config!.appearance.dark_palette[field.key] = v; | |
| 326 | - | }} | |
| 327 | - | class="h-10 w-12 border border-input bg-background" | |
| 328 | - | /> | |
| 329 | - | <input | |
| 330 | - | type="text" | |
| 331 | - | bind:value={config.appearance.dark_palette[field.key]} | |
| 332 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 333 | - | /> | |
| 332 | + | <div class="space-y-3"> | |
| 333 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |
| 334 | + | Theme: Dark | |
| 335 | + | </div> | |
| 336 | + | <div class="grid gap-4 md:grid-cols-2"> | |
| 337 | + | {#each PALETTE_FIELDS as field (field.key)} | |
| 338 | + | <div class="space-y-2"> | |
| 339 | + | <Label for={`dark_${field.key}`}>{field.label}</Label> | |
| 340 | + | <div class="flex items-center gap-3"> | |
| 341 | + | <input | |
| 342 | + | id={`dark_${field.key}`} | |
| 343 | + | type="color" | |
| 344 | + | value={config.appearance.dark_palette[field.key]} | |
| 345 | + | oninput={(e) => { | |
| 346 | + | const v = (e.currentTarget as HTMLInputElement).value; | |
| 347 | + | config!.appearance.dark_palette[field.key] = v; | |
| 348 | + | }} | |
| 349 | + | class="h-10 w-12 border border-border bg-background" | |
| 350 | + | /> | |
| 351 | + | <Input bind:value={config.appearance.dark_palette[field.key]} /> | |
| 352 | + | </div> | |
| 353 | + | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |
| 354 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 355 | + | {/if} | |
| 356 | + | </div> | |
| 357 | + | {/each} | |
| 334 | 358 | </div> | |
| 335 | - | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |
| 336 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 337 | - | {/if} | |
| 338 | 359 | </div> | |
| 339 | - | {/each} | |
| 340 | - | </div> | |
| 341 | - | </div> | |
| 360 | + | </div> | |
| 361 | + | </Card.Content> | |
| 362 | + | </Card.Root> | |
| 363 | + | </Tabs.Content> | |
| 364 | + | ||
| 365 | + | <Tabs.Content value="search"> | |
| 366 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 367 | + | <Card.Header> | |
| 368 | + | <Card.Title>Search</Card.Title> | |
| 369 | + | <Card.Description>Default engine and UI scaling.</Card.Description> | |
| 370 | + | </Card.Header> | |
| 371 | + | <Card.Content class="grid gap-6 md:grid-cols-2"> | |
| 372 | + | <div class="space-y-2"> | |
| 373 | + | <Label for="search_engine">Default Search Engine (use %s for query)</Label> | |
| 374 | + | <Input id="search_engine" bind:value={config.default_search_engine} /> | |
| 375 | + | </div> | |
| 376 | + | <div class="space-y-2"> | |
| 377 | + | <Label for="ui_scale_search">UI Scale</Label> | |
| 378 | + | <Input id="ui_scale_search" type="number" step="0.1" bind:value={config.default_ui_scale} /> | |
| 379 | + | </div> | |
| 380 | + | </Card.Content> | |
| 381 | + | </Card.Root> | |
| 382 | + | </Tabs.Content> | |
| 383 | + | ||
| 384 | + | <Tabs.Content value="shortcuts"> | |
| 385 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 386 | + | <Card.Header> | |
| 387 | + | <Card.Title>Search Shortcuts</Card.Title> | |
| 388 | + | <Card.Description>Map prefixes like g to URL templates.</Card.Description> | |
| 389 | + | </Card.Header> | |
| 390 | + | <Card.Content> | |
| 391 | + | <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} /> | |
| 392 | + | </Card.Content> | |
| 393 | + | </Card.Root> | |
| 394 | + | </Tabs.Content> | |
| 395 | + | </Tabs.Root> | |
| 396 | + | ||
| 397 | + | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | |
| 398 | + | <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground"> | |
| 399 | + | {#if saveSuccess} | |
| 400 | + | <span class="text-primary">Saved</span> | |
| 401 | + | {:else} | |
| 402 | + | <span>Ready</span> | |
| 403 | + | {/if} | |
| 404 | + | {#if themeEvents > 0} | |
| 405 | + | <span>Theme x{themeEvents}</span> | |
| 406 | + | {/if} | |
| 342 | 407 | </div> | |
| 343 | - | </section> | |
| 344 | - | ||
| 345 | - | <!-- Search Section --> | |
| 346 | - | <section class="border border-border bg-card p-6 text-card-foreground"> | |
| 347 | - | <h2 class="mb-6 text-xl font-semibold">Search</h2> | |
| 348 | - | <div class="space-y-4"> | |
| 349 | - | <div class="space-y-2"> | |
| 350 | - | <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label> | |
| 351 | - | <input | |
| 352 | - | id="search_engine" | |
| 353 | - | type="text" | |
| 354 | - | bind:value={config.default_search_engine} | |
| 355 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 356 | - | /> | |
| 357 | - | </div> | |
| 408 | + | <div class="flex items-center gap-3"> | |
| 409 | + | <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button"> | |
| 410 | + | Reset Defaults | |
| 411 | + | </Button> | |
| 412 | + | <Button onclick={saveConfig} disabled={saving} type="button"> | |
| 413 | + | {saving ? "Saving…" : "Save"} | |
| 414 | + | </Button> | |
| 358 | 415 | </div> | |
| 359 | - | </section> | |
| 360 | - | ||
| 361 | - | <div class="actions"> | |
| 362 | - | {#if saveSuccess} | |
| 363 | - | <span class="saved">SAVED</span> | |
| 364 | - | {/if} | |
| 365 | - | {#if themeEvents > 0} | |
| 366 | - | <span class="hint">THEME×{themeEvents}</span> | |
| 367 | - | {/if} | |
| 368 | - | <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button"> | |
| 369 | - | RESET DEFAULTS | |
| 370 | - | </button> | |
| 371 | - | <button onclick={saveConfig} disabled={saving} class="save-button" type="button"> | |
| 372 | - | {saving ? "SAVING…" : "SAVE"} | |
| 373 | - | </button> | |
| 374 | 416 | </div> | |
| 375 | - | </div> | |
| 376 | - | {/if} | |
| 377 | - | </div> | |
| 378 | - | </ConfigShell> | |
| 379 | - | ||
| 380 | - | <style> | |
| 381 | - | .config-content { | |
| 382 | - | width: 100%; | |
| 383 | - | max-width: 980px; | |
| 384 | - | margin: 0 auto; | |
| 385 | - | padding: 1.5rem 1.25rem 2rem; | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | .stack { | |
| 389 | - | display: flex; | |
| 390 | - | flex-direction: column; | |
| 391 | - | gap: 1.25rem; | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | .loading { | |
| 395 | - | padding: 2rem 0; | |
| 396 | - | color: var(--muted-foreground); | |
| 397 | - | letter-spacing: 0.1em; | |
| 398 | - | font-size: 0.8rem; | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | .error { | |
| 402 | - | padding: 0.75rem 1rem; | |
| 403 | - | border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%); | |
| 404 | - | background: color-mix(in srgb, var(--card) 70%, #ef4444 30%); | |
| 405 | - | display: flex; | |
| 406 | - | flex-direction: column; | |
| 407 | - | gap: 0.25rem; | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | .actions { | |
| 411 | - | display: flex; | |
| 412 | - | align-items: center; | |
| 413 | - | justify-content: flex-end; | |
| 414 | - | gap: 0.75rem; | |
| 415 | - | padding-top: 0.75rem; | |
| 416 | - | border-top: 1px solid var(--border); | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | .saved { | |
| 420 | - | font-size: 0.7rem; | |
| 421 | - | letter-spacing: 0.12em; | |
| 422 | - | color: var(--primary, #4ade80); | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | .hint { | |
| 426 | - | font-size: 0.7rem; | |
| 427 | - | letter-spacing: 0.12em; | |
| 428 | - | color: var(--muted-foreground); | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | .reset-button { | |
| 432 | - | display: inline-flex; | |
| 433 | - | align-items: center; | |
| 434 | - | justify-content: center; | |
| 435 | - | padding: 0.55rem 1rem; | |
| 436 | - | font-size: 0.7rem; | |
| 437 | - | font-weight: 600; | |
| 438 | - | letter-spacing: 0.12em; | |
| 439 | - | color: var(--foreground); | |
| 440 | - | background: transparent; | |
| 441 | - | border: 1px solid var(--border); | |
| 442 | - | cursor: pointer; | |
| 443 | - | transition: all 120ms ease; | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | .reset-button:hover { | |
| 447 | - | background: color-mix(in srgb, var(--card) 25%, transparent); | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | .reset-button:disabled { | |
| 451 | - | opacity: 0.6; | |
| 452 | - | cursor: not-allowed; | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | .save-button { | |
| 456 | - | display: inline-flex; | |
| 457 | - | align-items: center; | |
| 458 | - | justify-content: center; | |
| 459 | - | padding: 0.55rem 1rem; | |
| 460 | - | font-size: 0.75rem; | |
| 461 | - | font-weight: 600; | |
| 462 | - | letter-spacing: 0.12em; | |
| 463 | - | color: var(--background); | |
| 464 | - | background: var(--primary, #4ade80); | |
| 465 | - | border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%); | |
| 466 | - | cursor: pointer; | |
| 467 | - | transition: all 120ms ease; | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | .save-button:hover { | |
| 471 | - | filter: brightness(1.02); | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | .save-button:disabled { | |
| 475 | - | opacity: 0.6; | |
| 476 | - | cursor: not-allowed; | |
| 477 | - | } | |
| 478 | - | </style> | |
| 479 | 417 | ||
| 418 | + | <!-- Save error display --> | |
| 419 | + | {#if saveError} | |
| 420 | + | <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |
| 421 | + | {saveError} | |
| 422 | + | </div> | |
| 423 | + | {/if} | |
| 424 | + | {/if} | |
| 425 | + | </div> | |
| 426 | + | </ConfigShell> | |
| @@ -1,6 +1,13 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { onMount } from "svelte"; | 2 | import { onMount } from "svelte"; | ||
| 3 | import ConfigShell from "./config/ConfigShell.svelte"; | 3 | import ConfigShell from "./config/ConfigShell.svelte"; | ||
| 4 | + | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |||
| 5 | + | import { Button } from "$lib/components/ui/button"; | |||
| 6 | + | import { Input } from "$lib/components/ui/input"; | |||
| 7 | + | import { Label } from "$lib/components/ui/label"; | |||
| 8 | + | import { Spinner } from "$lib/components/ui/spinner"; | |||
| 9 | + | import * as Card from "$lib/components/ui/card"; | |||
| 10 | + | import * as Tabs from "$lib/components/ui/tabs"; | |||
| 4 | 11 | ||||
| 5 | type ColorPalette = { | 12 | type ColorPalette = { | ||
| 6 | background: string; | 13 | background: string; | ||
| @@ -12,6 +19,11 @@ | |||||
| 12 | border: string; | 19 | border: string; | ||
| 13 | }; | 20 | }; | ||
| 14 | 21 | ||||
| 22 | + | type SearchShortcut = { | |||
| 23 | + | url: string; | |||
| 24 | + | description: string; | |||
| 25 | + | }; | |||
| 26 | + | ||||
| 15 | type ConfigDTO = { | 27 | type ConfigDTO = { | ||
| 16 | appearance: { | 28 | appearance: { | ||
| 17 | sans_font: string; | 29 | sans_font: string; | ||
| @@ -24,6 +36,7 @@ | |||||
| 24 | }; | 36 | }; | ||
| 25 | default_ui_scale: number; | 37 | default_ui_scale: number; | ||
| 26 | default_search_engine: string; | 38 | default_search_engine: string; | ||
| 39 | + | search_shortcuts: Record<string, SearchShortcut>; | |||
| 27 | }; | 40 | }; | ||
| 28 | 41 | ||||
| 29 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | 42 | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | ||
| @@ -40,30 +53,39 @@ | |||||
| 40 | return /^#[0-9a-fA-F]{6}$/.test(value); | 53 | return /^#[0-9a-fA-F]{6}$/.test(value); | ||
| 41 | } | 54 | } | ||
| 42 | 55 | ||||
| 43 | - | 56 | + | // UI state | |
| 44 | - | let config = $state<ConfigDTO | null>(null); | |||
| 45 | let loading = $state(true); | 57 | let loading = $state(true); | ||
| 46 | - | let error = $state<string | null>(null); | 58 | + | let loadError = $state<string | null>(null); |
| 59 | + | let config = $state<ConfigDTO | null>(null); | |||
| 47 | let saving = $state(false); | 60 | let saving = $state(false); | ||
| 48 | let saveSuccess = $state(false); | 61 | let saveSuccess = $state(false); | ||
| 62 | + | let saveError = $state<string | null>(null); | |||
| 49 | let themeEvents = $state(0); | 63 | let themeEvents = $state(0); | ||
| 64 | + | let activeTab = $state("appearance"); | |||
| 50 | 65 | ||||
| 51 | - | async function fetchConfig() { | 66 | + | // Load config from API |
| 67 | + | async function loadConfig() { | |||
| 68 | + | loading = true; | |||
| 69 | + | loadError = null; | |||
| 52 | try { | 70 | try { | ||
| 53 | const response = await fetch("/api/config"); | 71 | const response = await fetch("/api/config"); | ||
| 54 | if (!response.ok) throw new Error("Failed to fetch config"); | 72 | if (!response.ok) throw new Error("Failed to fetch config"); | ||
| 55 | config = (await response.json()) as ConfigDTO; | 73 | config = (await response.json()) as ConfigDTO; | ||
| 56 | } catch (e: any) { | 74 | } catch (e: any) { | ||
| 57 | - | error = e.message; | 75 | + | loadError = e.message; |
| 76 | + | console.error("[config] load failed", e); | |||
| 58 | } finally { | 77 | } finally { | ||
| 59 | loading = false; | 78 | loading = false; | ||
| 60 | } | 79 | } | ||
| 61 | } | 80 | } | ||
| 62 | 81 | ||||
| 82 | + | // Reload config (used after save to get normalized values) | |||
| 83 | + | function reloadConfig() { | |||
| 84 | + | loadConfig(); | |||
| 85 | + | } | |||
| 86 | + | ||||
| 63 | async function resetToDefaults() { | 87 | async function resetToDefaults() { | ||
| 64 | - | error = null; | |||
| 65 | saveSuccess = false; | 88 | saveSuccess = false; | ||
| 66 | - | ||||
| 67 | const confirmed = window.confirm( | 89 | const confirmed = window.confirm( | ||
| 68 | "Reset this page to default settings? (You still need to click Save to persist.)", | 90 | "Reset this page to default settings? (You still need to click Save to persist.)", | ||
| 69 | ); | 91 | ); | ||
| @@ -74,7 +96,7 @@ | |||||
| 74 | if (!response.ok) throw new Error("Failed to fetch default config"); | 96 | if (!response.ok) throw new Error("Failed to fetch default config"); | ||
| 75 | config = (await response.json()) as ConfigDTO; | 97 | config = (await response.json()) as ConfigDTO; | ||
| 76 | } catch (e: any) { | 98 | } catch (e: any) { | ||
| 77 | - | error = e.message; | 99 | + | console.error("[config] reset defaults failed", e); |
| 78 | } | 100 | } | ||
| 79 | } | 101 | } | ||
| 80 | 102 | ||||
| @@ -93,7 +115,7 @@ | |||||
| 93 | async function saveConfig() { | 115 | async function saveConfig() { | ||
| 94 | saving = true; | 116 | saving = true; | ||
| 95 | saveSuccess = false; | 117 | saveSuccess = false; | ||
| 96 | - | error = null; | 118 | + | saveError = null; |
| 97 | 119 | ||||
| 98 | try { | 120 | try { | ||
| 99 | const bridge = getWebKitBridge(); | 121 | const bridge = getWebKitBridge(); | ||
| @@ -118,7 +140,7 @@ | |||||
| 118 | let timeout: number | null = window.setTimeout(() => { | 140 | let timeout: number | null = window.setTimeout(() => { | ||
| 119 | if (saving) { | 141 | if (saving) { | ||
| 120 | console.warn("[config] save timed out (no callback)"); | 142 | console.warn("[config] save timed out (no callback)"); | ||
| 121 | - | error = "Save timed out (no response from native handler)"; | 143 | + | saveError = "Save timed out (no response from native handler)"; |
| 122 | saving = false; | 144 | saving = false; | ||
| 123 | } | 145 | } | ||
| 124 | }, 8000); | 146 | }, 8000); | ||
| @@ -136,8 +158,8 @@ | |||||
| 136 | saveSuccess = true; | 158 | saveSuccess = true; | ||
| 137 | saving = false; | 159 | saving = false; | ||
| 138 | 160 | ||||
| 139 | - | // Refresh config state from backend (in case watcher normalized values) | 161 | + | // Refresh config from backend (in case watcher normalized values) |
| 140 | - | fetchConfig(); | 162 | + | reloadConfig(); |
| 141 | 163 | ||||
| 142 | setTimeout(() => { | 164 | setTimeout(() => { | ||
| 143 | saveSuccess = false; | 165 | saveSuccess = false; | ||
| @@ -146,7 +168,7 @@ | |||||
| 146 | (window as any).__dumber_config_error = (msg: unknown) => { | 168 | (window as any).__dumber_config_error = (msg: unknown) => { | ||
| 147 | clearSaveTimeout(); | 169 | clearSaveTimeout(); | ||
| 148 | console.error("[config] save error", msg); | 170 | console.error("[config] save error", msg); | ||
| 149 | - | error = typeof msg === "string" ? msg : "Failed to save config"; | 171 | + | saveError = typeof msg === "string" ? msg : "Failed to save config"; |
| 150 | saving = false; | 172 | saving = false; | ||
| 151 | }; | 173 | }; | ||
| 152 | 174 | ||||
| @@ -165,11 +187,16 @@ | |||||
| 165 | } | 187 | } | ||
| 166 | } catch (e: any) { | 188 | } catch (e: any) { | ||
| 167 | console.error("[config] save exception", e); | 189 | console.error("[config] save exception", e); | ||
| 168 | - | error = e.message; | 190 | + | saveError = e.message; |
| 169 | saving = false; | 191 | saving = false; | ||
| 170 | } | 192 | } | ||
| 171 | } | 193 | } | ||
| 172 | 194 | ||||
| 195 | + | function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) { | |||
| 196 | + | if (!config) return; | |||
| 197 | + | config.search_shortcuts = nextShortcuts; | |||
| 198 | + | } | |||
| 199 | + | ||||
| 173 | onMount(() => { | 200 | onMount(() => { | ||
| 174 | console.debug("[config] mount", { | 201 | console.debug("[config] mount", { | ||
| 175 | webviewId: (window as any).__dumber_webview_id, | 202 | webviewId: (window as any).__dumber_webview_id, | ||
| @@ -180,300 +207,220 @@ | |||||
| 180 | themeEvents += 1; | 207 | themeEvents += 1; | ||
| 181 | console.debug("[config] theme changed", (e as CustomEvent).detail); | 208 | console.debug("[config] theme changed", (e as CustomEvent).detail); | ||
| 182 | }; | 209 | }; | ||
| 183 | - | window.addEventListener('dumber:theme-changed', onThemeChanged); | 210 | + | window.addEventListener("dumber:theme-changed", onThemeChanged); |
| 184 | 211 | ||||
| 185 | - | fetchConfig(); | 212 | + | // Load config on mount |
| 213 | + | loadConfig(); | |||
| 186 | 214 | ||||
| 187 | return () => { | 215 | return () => { | ||
| 188 | - | window.removeEventListener('dumber:theme-changed', onThemeChanged); | 216 | + | window.removeEventListener("dumber:theme-changed", onThemeChanged); |
| 189 | }; | 217 | }; | ||
| 190 | }); | 218 | }); | ||
| 191 | </script> | 219 | </script> | ||
| 192 | 220 | ||||
| 193 | <ConfigShell> | 221 | <ConfigShell> | ||
| 194 | - | <div class="config-content"> | 222 | + | <div class="flex w-full flex-col gap-6 px-6 py-8"> |
| 195 | {#if loading} | 223 | {#if loading} | ||
| 196 | - | <div class="loading">LOADING…</div> | 224 | + | <!-- Loading state with spinner --> |
| 197 | - | {:else if error} | 225 | + | <div class="flex flex-col items-center justify-center gap-4 py-16"> |
| 198 | - | <div class="error"> | 226 | + | <Spinner class="size-8" /> |
| 199 | - | <strong>ERROR</strong> | 227 | + | <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground"> |
| 200 | - | <span>{error}</span> | 228 | + | Loading configuration… |
| 229 | + | </div> | |||
| 201 | </div> | 230 | </div> | ||
| 202 | - | {/if} | 231 | + | {:else if loadError} |
| 203 | - | 232 | + | <!-- Error state --> | |
| 204 | - | {#if config} | 233 | + | <div class="flex flex-col items-center justify-center gap-4 py-16"> |
| 205 | - | <div class="stack"> | 234 | + | <Card.Root class="max-w-md"> |
| 206 | - | <!-- Appearance Section --> | 235 | + | <Card.Header> |
| 207 | - | <section class="border border-border bg-card p-6 text-card-foreground"> | 236 | + | <Card.Title class="text-destructive">Failed to load config</Card.Title> |
| 208 | - | <h2 class="mb-6 text-xl font-semibold">Appearance</h2> | 237 | + | <Card.Description>{loadError}</Card.Description> |
| 209 | - | 238 | + | </Card.Header> | |
| 210 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | 239 | + | <Card.Content> |
| 211 | - | <div class="space-y-2"> | 240 | + | <Button variant="outline" onclick={reloadConfig}>Try again</Button> |
| 212 | - | <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label> | 241 | + | </Card.Content> |
| 213 | - | <input | 242 | + | </Card.Root> |
| 214 | - | id="sans_font" | 243 | + | </div> |
| 215 | - | type="text" | 244 | + | {:else if config} |
| 216 | - | bind:value={config.appearance.sans_font} | 245 | + | <Tabs.Root bind:value={activeTab} class="w-full"> |
| 217 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | 246 | + | <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> |
| 218 | - | /> | |||
| 219 | - | </div> | |||
| 220 | - | ||||
| 221 | - | <div class="space-y-2"> | |||
| 222 | - | <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label> | |||
| 223 | - | <input | |||
| 224 | - | id="serif_font" | |||
| 225 | - | type="text" | |||
| 226 | - | bind:value={config.appearance.serif_font} | |||
| 227 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 228 | - | /> | |||
| 229 | - | </div> | |||
| 230 | - | ||||
| 231 | - | <div class="space-y-2"> | |||
| 232 | - | <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label> | |||
| 233 | - | <input | |||
| 234 | - | id="monospace_font" | |||
| 235 | - | type="text" | |||
| 236 | - | bind:value={config.appearance.monospace_font} | |||
| 237 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 238 | - | /> | |||
| 239 | - | </div> | |||
| 240 | - | ||||
| 241 | - | <div class="space-y-2"> | |||
| 242 | - | <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label> | |||
| 243 | - | <input | |||
| 244 | - | id="font_size" | |||
| 245 | - | type="number" | |||
| 246 | - | bind:value={config.appearance.default_font_size} | |||
| 247 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 248 | - | /> | |||
| 249 | - | </div> | |||
| 250 | - | ||||
| 251 | - | <div class="space-y-2"> | |||
| 252 | - | <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label> | |||
| 253 | - | <select | |||
| 254 | - | id="color_scheme" | |||
| 255 | - | bind:value={config.appearance.color_scheme} | |||
| 256 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 257 | - | > | |||
| 258 | - | <option value="default">Follow System</option> | |||
| 259 | - | <option value="prefer-dark">Always Dark</option> | |||
| 260 | - | <option value="prefer-light">Always Light</option> | |||
| 261 | - | </select> | |||
| 262 | - | </div> | |||
| 263 | - | ||||
| 264 | <div class="space-y-2"> | 247 | <div class="space-y-2"> | ||
| 265 | - | <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label> | 248 | + | <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground"> |
| 266 | - | <input | 249 | + | Config Workspace |
| 267 | - | id="ui_scale" | 250 | + | </div> |
| 268 | - | type="number" | 251 | + | <div class="text-2xl font-semibold text-foreground">dumb://config</div> |
| 269 | - | step="0.1" | |||
| 270 | - | bind:value={config.default_ui_scale} | |||
| 271 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 272 | - | /> | |||
| 273 | </div> | 252 | </div> | ||
| 253 | + | <Tabs.List class="bg-muted/60"> | |||
| 254 | + | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |||
| 255 | + | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |||
| 256 | + | <Tabs.Trigger value="shortcuts">Shortcuts</Tabs.Trigger> | |||
| 257 | + | </Tabs.List> | |||
| 274 | </div> | 258 | </div> | ||
| 275 | 259 | ||||
| 276 | - | <div class="mt-8 space-y-8"> | 260 | + | <Tabs.Content value="appearance"> |
| 277 | - | <div> | 261 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> |
| 278 | - | <h3 class="mb-3 text-base font-semibold">Theme: Light</h3> | 262 | + | <Card.Header> |
| 279 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | 263 | + | <Card.Title>Appearance</Card.Title> |
| 280 | - | {#each PALETTE_FIELDS as field (field.key)} | 264 | + | <Card.Description>Fonts, palette, and theme preferences.</Card.Description> |
| 265 | + | </Card.Header> | |||
| 266 | + | <Card.Content class="space-y-8"> | |||
| 267 | + | <div class="grid gap-6 md:grid-cols-2"> | |||
| 268 | + | <div class="space-y-2"> | |||
| 269 | + | <Label for="sans_font">Sans-Serif Font</Label> | |||
| 270 | + | <Input id="sans_font" bind:value={config.appearance.sans_font} /> | |||
| 271 | + | </div> | |||
| 281 | <div class="space-y-2"> | 272 | <div class="space-y-2"> | ||
| 282 | - | <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`} | 273 | + | <Label for="serif_font">Serif Font</Label> |
| 283 | - | >{field.label}</label | 274 | + | <Input id="serif_font" bind:value={config.appearance.serif_font} /> |
| 275 | + | </div> | |||
| 276 | + | <div class="space-y-2"> | |||
| 277 | + | <Label for="monospace_font">Monospace Font</Label> | |||
| 278 | + | <Input id="monospace_font" bind:value={config.appearance.monospace_font} /> | |||
| 279 | + | </div> | |||
| 280 | + | <div class="space-y-2"> | |||
| 281 | + | <Label for="font_size">Default Font Size</Label> | |||
| 282 | + | <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} /> | |||
| 283 | + | </div> | |||
| 284 | + | <div class="space-y-2"> | |||
| 285 | + | <Label for="color_scheme">Color Scheme</Label> | |||
| 286 | + | <select | |||
| 287 | + | id="color_scheme" | |||
| 288 | + | bind:value={config.appearance.color_scheme} | |||
| 289 | + | class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |||
| 284 | > | 290 | > | ||
| 285 | - | <div class="flex items-center gap-3"> | 291 | + | <option value="default">Follow System</option> |
| 286 | - | <input | 292 | + | <option value="prefer-dark">Always Dark</option> |
| 287 | - | id={`light_${field.key}`} | 293 | + | <option value="prefer-light">Always Light</option> |
| 288 | - | type="color" | 294 | + | </select> |
| 289 | - | value={config.appearance.light_palette[field.key]} | 295 | + | </div> |
| 290 | - | oninput={(e) => { | 296 | + | <div class="space-y-2"> |
| 291 | - | const v = (e.currentTarget as HTMLInputElement).value; | 297 | + | <Label for="ui_scale">UI Scale</Label> |
| 292 | - | config!.appearance.light_palette[field.key] = v; | 298 | + | <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} /> |
| 293 | - | }} | 299 | + | </div> |
| 294 | - | class="h-10 w-12 border border-input bg-background" | 300 | + | </div> |
| 295 | - | /> | 301 | + | |
| 296 | - | <input | 302 | + | <div class="space-y-6"> |
| 297 | - | type="text" | 303 | + | <div class="space-y-3"> |
| 298 | - | bind:value={config.appearance.light_palette[field.key]} | 304 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> |
| 299 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | 305 | + | Theme: Light |
| 300 | - | /> | 306 | + | </div> |
| 307 | + | <div class="grid gap-4 md:grid-cols-2"> | |||
| 308 | + | {#each PALETTE_FIELDS as field (field.key)} | |||
| 309 | + | <div class="space-y-2"> | |||
| 310 | + | <Label for={`light_${field.key}`}>{field.label}</Label> | |||
| 311 | + | <div class="flex items-center gap-3"> | |||
| 312 | + | <input | |||
| 313 | + | id={`light_${field.key}`} | |||
| 314 | + | type="color" | |||
| 315 | + | value={config.appearance.light_palette[field.key]} | |||
| 316 | + | oninput={(e) => { | |||
| 317 | + | const v = (e.currentTarget as HTMLInputElement).value; | |||
| 318 | + | config!.appearance.light_palette[field.key] = v; | |||
| 319 | + | }} | |||
| 320 | + | class="h-10 w-12 border border-border bg-background" | |||
| 321 | + | /> | |||
| 322 | + | <Input bind:value={config.appearance.light_palette[field.key]} /> | |||
| 323 | + | </div> | |||
| 324 | + | {#if !isHexColor(config.appearance.light_palette[field.key])} | |||
| 325 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 326 | + | {/if} | |||
| 327 | + | </div> | |||
| 328 | + | {/each} | |||
| 301 | </div> | 329 | </div> | ||
| 302 | - | {#if !isHexColor(config.appearance.light_palette[field.key])} | |||
| 303 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 304 | - | {/if} | |||
| 305 | </div> | 330 | </div> | ||
| 306 | - | {/each} | |||
| 307 | - | </div> | |||
| 308 | - | </div> | |||
| 309 | 331 | ||||
| 310 | - | <div> | 332 | + | <div class="space-y-3"> |
| 311 | - | <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3> | 333 | + | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> |
| 312 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | 334 | + | Theme: Dark |
| 313 | - | {#each PALETTE_FIELDS as field (field.key)} | 335 | + | </div> |
| 314 | - | <div class="space-y-2"> | 336 | + | <div class="grid gap-4 md:grid-cols-2"> |
| 315 | - | <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`} | 337 | + | {#each PALETTE_FIELDS as field (field.key)} |
| 316 | - | >{field.label}</label | 338 | + | <div class="space-y-2"> |
| 317 | - | > | 339 | + | <Label for={`dark_${field.key}`}>{field.label}</Label> |
| 318 | - | <div class="flex items-center gap-3"> | 340 | + | <div class="flex items-center gap-3"> |
| 319 | - | <input | 341 | + | <input |
| 320 | - | id={`dark_${field.key}`} | 342 | + | id={`dark_${field.key}`} |
| 321 | - | type="color" | 343 | + | type="color" |
| 322 | - | value={config.appearance.dark_palette[field.key]} | 344 | + | value={config.appearance.dark_palette[field.key]} |
| 323 | - | oninput={(e) => { | 345 | + | oninput={(e) => { |
| 324 | - | const v = (e.currentTarget as HTMLInputElement).value; | 346 | + | const v = (e.currentTarget as HTMLInputElement).value; |
| 325 | - | config!.appearance.dark_palette[field.key] = v; | 347 | + | config!.appearance.dark_palette[field.key] = v; |
| 326 | - | }} | 348 | + | }} |
| 327 | - | class="h-10 w-12 border border-input bg-background" | 349 | + | class="h-10 w-12 border border-border bg-background" |
| 328 | - | /> | 350 | + | /> |
| 329 | - | <input | 351 | + | <Input bind:value={config.appearance.dark_palette[field.key]} /> |
| 330 | - | type="text" | 352 | + | </div> |
| 331 | - | bind:value={config.appearance.dark_palette[field.key]} | 353 | + | {#if !isHexColor(config.appearance.dark_palette[field.key])} |
| 332 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | 354 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> |
| 333 | - | /> | 355 | + | {/if} |
| 356 | + | </div> | |||
| 357 | + | {/each} | |||
| 334 | </div> | 358 | </div> | ||
| 335 | - | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |||
| 336 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 337 | - | {/if} | |||
| 338 | </div> | 359 | </div> | ||
| 339 | - | {/each} | 360 | + | </div> |
| 340 | - | </div> | 361 | + | </Card.Content> |
| 341 | - | </div> | 362 | + | </Card.Root> |
| 363 | + | </Tabs.Content> | |||
| 364 | + | ||||
| 365 | + | <Tabs.Content value="search"> | |||
| 366 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 367 | + | <Card.Header> | |||
| 368 | + | <Card.Title>Search</Card.Title> | |||
| 369 | + | <Card.Description>Default engine and UI scaling.</Card.Description> | |||
| 370 | + | </Card.Header> | |||
| 371 | + | <Card.Content class="grid gap-6 md:grid-cols-2"> | |||
| 372 | + | <div class="space-y-2"> | |||
| 373 | + | <Label for="search_engine">Default Search Engine (use %s for query)</Label> | |||
| 374 | + | <Input id="search_engine" bind:value={config.default_search_engine} /> | |||
| 375 | + | </div> | |||
| 376 | + | <div class="space-y-2"> | |||
| 377 | + | <Label for="ui_scale_search">UI Scale</Label> | |||
| 378 | + | <Input id="ui_scale_search" type="number" step="0.1" bind:value={config.default_ui_scale} /> | |||
| 379 | + | </div> | |||
| 380 | + | </Card.Content> | |||
| 381 | + | </Card.Root> | |||
| 382 | + | </Tabs.Content> | |||
| 383 | + | ||||
| 384 | + | <Tabs.Content value="shortcuts"> | |||
| 385 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 386 | + | <Card.Header> | |||
| 387 | + | <Card.Title>Search Shortcuts</Card.Title> | |||
| 388 | + | <Card.Description>Map prefixes like g to URL templates.</Card.Description> | |||
| 389 | + | </Card.Header> | |||
| 390 | + | <Card.Content> | |||
| 391 | + | <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} /> | |||
| 392 | + | </Card.Content> | |||
| 393 | + | </Card.Root> | |||
| 394 | + | </Tabs.Content> | |||
| 395 | + | </Tabs.Root> | |||
| 396 | + | ||||
| 397 | + | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | |||
| 398 | + | <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground"> | |||
| 399 | + | {#if saveSuccess} | |||
| 400 | + | <span class="text-primary">Saved</span> | |||
| 401 | + | {:else} | |||
| 402 | + | <span>Ready</span> | |||
| 403 | + | {/if} | |||
| 404 | + | {#if themeEvents > 0} | |||
| 405 | + | <span>Theme x{themeEvents}</span> | |||
| 406 | + | {/if} | |||
| 342 | </div> | 407 | </div> | ||
| 343 | - | </section> | 408 | + | <div class="flex items-center gap-3"> |
| 344 | - | 409 | + | <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button"> | |
| 345 | - | <!-- Search Section --> | 410 | + | Reset Defaults |
| 346 | - | <section class="border border-border bg-card p-6 text-card-foreground"> | 411 | + | </Button> |
| 347 | - | <h2 class="mb-6 text-xl font-semibold">Search</h2> | 412 | + | <Button onclick={saveConfig} disabled={saving} type="button"> |
| 348 | - | <div class="space-y-4"> | 413 | + | {saving ? "Saving…" : "Save"} |
| 349 | - | <div class="space-y-2"> | 414 | + | </Button> |
| 350 | - | <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label> | |||
| 351 | - | <input | |||
| 352 | - | id="search_engine" | |||
| 353 | - | type="text" | |||
| 354 | - | bind:value={config.default_search_engine} | |||
| 355 | - | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 356 | - | /> | |||
| 357 | - | </div> | |||
| 358 | </div> | 415 | </div> | ||
| 359 | - | </section> | |||
| 360 | - | ||||
| 361 | - | <div class="actions"> | |||
| 362 | - | {#if saveSuccess} | |||
| 363 | - | <span class="saved">SAVED</span> | |||
| 364 | - | {/if} | |||
| 365 | - | {#if themeEvents > 0} | |||
| 366 | - | <span class="hint">THEME×{themeEvents}</span> | |||
| 367 | - | {/if} | |||
| 368 | - | <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button"> | |||
| 369 | - | RESET DEFAULTS | |||
| 370 | - | </button> | |||
| 371 | - | <button onclick={saveConfig} disabled={saving} class="save-button" type="button"> | |||
| 372 | - | {saving ? "SAVING…" : "SAVE"} | |||
| 373 | - | </button> | |||
| 374 | </div> | 416 | </div> | ||
| 375 | - | </div> | |||
| 376 | - | {/if} | |||
| 377 | - | </div> | |||
| 378 | - | </ConfigShell> | |||
| 379 | - | ||||
| 380 | - | <style> | |||
| 381 | - | .config-content { | |||
| 382 | - | width: 100%; | |||
| 383 | - | max-width: 980px; | |||
| 384 | - | margin: 0 auto; | |||
| 385 | - | padding: 1.5rem 1.25rem 2rem; | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | .stack { | |||
| 389 | - | display: flex; | |||
| 390 | - | flex-direction: column; | |||
| 391 | - | gap: 1.25rem; | |||
| 392 | - | } | |||
| 393 | - | ||||
| 394 | - | .loading { | |||
| 395 | - | padding: 2rem 0; | |||
| 396 | - | color: var(--muted-foreground); | |||
| 397 | - | letter-spacing: 0.1em; | |||
| 398 | - | font-size: 0.8rem; | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | .error { | |||
| 402 | - | padding: 0.75rem 1rem; | |||
| 403 | - | border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%); | |||
| 404 | - | background: color-mix(in srgb, var(--card) 70%, #ef4444 30%); | |||
| 405 | - | display: flex; | |||
| 406 | - | flex-direction: column; | |||
| 407 | - | gap: 0.25rem; | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | .actions { | |||
| 411 | - | display: flex; | |||
| 412 | - | align-items: center; | |||
| 413 | - | justify-content: flex-end; | |||
| 414 | - | gap: 0.75rem; | |||
| 415 | - | padding-top: 0.75rem; | |||
| 416 | - | border-top: 1px solid var(--border); | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | .saved { | |||
| 420 | - | font-size: 0.7rem; | |||
| 421 | - | letter-spacing: 0.12em; | |||
| 422 | - | color: var(--primary, #4ade80); | |||
| 423 | - | } | |||
| 424 | - | ||||
| 425 | - | .hint { | |||
| 426 | - | font-size: 0.7rem; | |||
| 427 | - | letter-spacing: 0.12em; | |||
| 428 | - | color: var(--muted-foreground); | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | .reset-button { | |||
| 432 | - | display: inline-flex; | |||
| 433 | - | align-items: center; | |||
| 434 | - | justify-content: center; | |||
| 435 | - | padding: 0.55rem 1rem; | |||
| 436 | - | font-size: 0.7rem; | |||
| 437 | - | font-weight: 600; | |||
| 438 | - | letter-spacing: 0.12em; | |||
| 439 | - | color: var(--foreground); | |||
| 440 | - | background: transparent; | |||
| 441 | - | border: 1px solid var(--border); | |||
| 442 | - | cursor: pointer; | |||
| 443 | - | transition: all 120ms ease; | |||
| 444 | - | } | |||
| 445 | - | ||||
| 446 | - | .reset-button:hover { | |||
| 447 | - | background: color-mix(in srgb, var(--card) 25%, transparent); | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | .reset-button:disabled { | |||
| 451 | - | opacity: 0.6; | |||
| 452 | - | cursor: not-allowed; | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | .save-button { | |||
| 456 | - | display: inline-flex; | |||
| 457 | - | align-items: center; | |||
| 458 | - | justify-content: center; | |||
| 459 | - | padding: 0.55rem 1rem; | |||
| 460 | - | font-size: 0.75rem; | |||
| 461 | - | font-weight: 600; | |||
| 462 | - | letter-spacing: 0.12em; | |||
| 463 | - | color: var(--background); | |||
| 464 | - | background: var(--primary, #4ade80); | |||
| 465 | - | border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%); | |||
| 466 | - | cursor: pointer; | |||
| 467 | - | transition: all 120ms ease; | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | .save-button:hover { | |||
| 471 | - | filter: brightness(1.02); | |||
| 472 | - | } | |||
| 473 | - | ||||
| 474 | - | .save-button:disabled { | |||
| 475 | - | opacity: 0.6; | |||
| 476 | - | cursor: not-allowed; | |||
| 477 | - | } | |||
| 478 | - | </style> | |||
| 479 | 417 | ||||
| 418 | + | <!-- Save error display --> | |||
| 419 | + | {#if saveError} | |||
| 420 | + | <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |||
| 421 | + | {saveError} | |||
| 422 | + | </div> | |||
| 423 | + | {/if} | |||
| 424 | + | {/if} | |||
| 425 | + | </div> | |||
| 426 | + | </ConfigShell> | |||