1<script lang="ts">
2 import { onMount } from "svelte";
3 import ConfigShell from "./config/ConfigShell.svelte";
4 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5 import * as AlertDialog from "$lib/components/ui/alert-dialog";
6 import { Button } from "$lib/components/ui/button";
7 import { ColorPicker } from "$lib/components/ui/color-picker";
8 import { Input } from "$lib/components/ui/input";
9 import { Label } from "$lib/components/ui/label";
10 import { Spinner } from "$lib/components/ui/spinner";
11 import * as Card from "$lib/components/ui/card";
12 import * as Tabs from "$lib/components/ui/tabs";
13
14 type ColorPalette = {
15 background: string;
16 surface: string;
17 surface_variant: string;
18 text: string;
19 muted: string;
20 accent: string;
21 border: string;
22 };
23
24 type SearchShortcut = {
25 url: string;
26 description: string;
27 };
28
29 type ConfigDTO = {
30 appearance: {
31 sans_font: string;
32 serif_font: string;
33 monospace_font: string;
34 default_font_size: number;
35 color_scheme: string;
36 light_palette: ColorPalette;
37 dark_palette: ColorPalette;
38 };
39 default_ui_scale: number;
40 default_search_engine: string;
41 search_shortcuts: Record<string, SearchShortcut>;
42 };
43
44 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
45 { key: "background", label: "Background" },
46 { key: "surface", label: "Surface" },
47 { key: "surface_variant", label: "Surface Variant" },
48 { key: "text", label: "Text" },
49 { key: "muted", label: "Muted" },
50 { key: "accent", label: "Accent" },
51 { key: "border", label: "Border" },
52 ];
53
54 function isHexColor(value: string): boolean {
55 return /^#[0-9a-fA-F]{6}$/.test(value);
56 }
57
58 // UI state
59 let loading = $state(true);
60 let loadError = $state<string | null>(null);
61 let config = $state<ConfigDTO | null>(null);
62 let saving = $state(false);
63 let saveSuccess = $state(false);
64 let saveError = $state<string | null>(null);
65 let themeEvents = $state(0);
66 let activeTab = $state("appearance");
67
68 // Load config from API
69 async function loadConfig() {
70 loading = true;
71 loadError = null;
72 try {
73 const response = await fetch("/api/config");
74 if (!response.ok) throw new Error("Failed to fetch config");
75 config = (await response.json()) as ConfigDTO;
76 } catch (e: any) {
77 loadError = e.message;
78 console.error("[config] load failed", e);
79 } finally {
80 loading = false;
81 }
82 }
83
84 // Reload config (used after save to get normalized values)
85 function reloadConfig() {
86 loadConfig();
87 }
88
89 let resetDialogOpen = $state(false);
90
91 async function doResetToDefaults() {
92 saveSuccess = false;
93 try {
94 const response = await fetch("/api/config/default");
95 if (!response.ok) throw new Error("Failed to fetch default config");
96 config = (await response.json()) as ConfigDTO;
97 } catch (e: any) {
98 console.error("[config] reset defaults failed", e);
99 }
100 resetDialogOpen = false;
101 }
102
103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
104 const bridge = (window as any).webkit?.messageHandlers?.dumber;
105 if (bridge && typeof bridge.postMessage === "function") {
106 return bridge;
107 }
108 return null;
109 }
110
111 function getWebViewId(): number {
112 return (window as any).__dumber_webview_id || 0;
113 }
114
115 async function saveConfig() {
116 saving = true;
117 saveSuccess = false;
118 saveError = null;
119
120 try {
121 const bridge = getWebKitBridge();
122 const webviewId = getWebViewId();
123
124 console.debug("[config] save click", {
125 hasBridge: Boolean(bridge),
126 webviewId,
127 hasConfig: Boolean(config),
128 });
129
130 if (!bridge) {
131 throw new Error("WebKit bridge not available (not running inside Dumber)");
132 }
133 if (!webviewId) {
134 throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
135 }
136 if (!config) {
137 throw new Error("Config not loaded");
138 }
139
140 let timeout: number | null = window.setTimeout(() => {
141 if (saving) {
142 console.warn("[config] save timed out (no callback)");
143 saveError = "Save timed out (no response from native handler)";
144 saving = false;
145 }
146 }, 8000);
147
148 const clearSaveTimeout = () => {
149 if (timeout != null) {
150 window.clearTimeout(timeout);
151 timeout = null;
152 }
153 };
154
155 (window as any).__dumber_config_saved = (resp?: unknown) => {
156 clearSaveTimeout();
157 console.debug("[config] save success", resp);
158 saveSuccess = true;
159 saving = false;
160
161 // Refresh config from backend (in case watcher normalized values)
162 reloadConfig();
163
164 setTimeout(() => {
165 saveSuccess = false;
166 }, 3000);
167 };
168 (window as any).__dumber_config_error = (msg: unknown) => {
169 clearSaveTimeout();
170 console.error("[config] save error", msg);
171 saveError = typeof msg === "string" ? msg : "Failed to save config";
172 saving = false;
173 };
174
175 const payload = $state.snapshot(config);
176
177 try {
178 bridge.postMessage({
179 type: "save_config",
180 webview_id: webviewId,
181 payload,
182 });
183 console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length });
184 } catch (postErr) {
185 console.error("[config] postMessage threw", postErr);
186 throw postErr;
187 }
188 } catch (e: any) {
189 console.error("[config] save exception", e);
190 saveError = e.message;
191 saving = false;
192 }
193 }
194
195 function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
196 if (!config) return;
197 config.search_shortcuts = nextShortcuts;
198 }
199
200 onMount(() => {
201 console.debug("[config] mount", {
202 webviewId: (window as any).__dumber_webview_id,
203 hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber),
204 });
205
206 const onThemeChanged = (e: Event) => {
207 themeEvents += 1;
208 console.debug("[config] theme changed", (e as CustomEvent).detail);
209 };
210 window.addEventListener("dumber:theme-changed", onThemeChanged);
211
212 // Load config on mount
213 loadConfig();
214
215 return () => {
216 window.removeEventListener("dumber:theme-changed", onThemeChanged);
217 };
218 });
219</script>
220
221<ConfigShell>
222 <div class="flex w-full flex-col gap-6 px-6 py-8">
223 {#if loading}
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>
230 </div>
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">
247 <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
248 Config Workspace
249 </div>
250 <Tabs.List class="bg-muted/60">
251 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
252 <Tabs.Trigger value="search">Search</Tabs.Trigger>
253 </Tabs.List>
254 </div>
255
256 <Tabs.Content value="appearance">
257 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
258 <Card.Header>
259 <Card.Title>Appearance</Card.Title>
260 <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
261 </Card.Header>
262 <Card.Content class="space-y-8">
263 <div class="grid gap-6 md:grid-cols-2">
264 <div class="space-y-2">
265 <Label for="sans_font">Sans-Serif Font</Label>
266 <Input id="sans_font" bind:value={config.appearance.sans_font} />
267 </div>
268 <div class="space-y-2">
269 <Label for="serif_font">Serif Font</Label>
270 <Input id="serif_font" bind:value={config.appearance.serif_font} />
271 </div>
272 <div class="space-y-2">
273 <Label for="monospace_font">Monospace Font</Label>
274 <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
275 </div>
276 <div class="space-y-2">
277 <Label for="font_size">Default Font Size</Label>
278 <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
279 </div>
280 <div class="space-y-2">
281 <Label for="color_scheme">Color Scheme</Label>
282 <select
283 id="color_scheme"
284 bind:value={config.appearance.color_scheme}
285 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"
286 >
287 <option value="default">Follow System</option>
288 <option value="prefer-dark">Always Dark</option>
289 <option value="prefer-light">Always Light</option>
290 </select>
291 </div>
292 <div class="space-y-2">
293 <Label for="ui_scale">UI Scale</Label>
294 <Input id="ui_scale" type="number" step="0.1" bind:value={config.default_ui_scale} />
295 </div>
296 </div>
297
298 <div class="space-y-6">
299 <div class="space-y-3">
300 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
301 Theme: Light
302 </div>
303 <div class="grid gap-4 md:grid-cols-2">
304 {#each PALETTE_FIELDS as field (field.key)}
305 <div class="space-y-2">
306 <Label for={`light_${field.key}`}>{field.label}</Label>
307 <div class="flex items-center gap-3">
308 <ColorPicker
309 id={`light_${field.key}`}
310 value={config.appearance.light_palette[field.key]}
311 onValueChange={(v) => {
312 config!.appearance.light_palette[field.key] = v;
313 }}
314 />
315 <Input bind:value={config.appearance.light_palette[field.key]} />
316 </div>
317 {#if !isHexColor(config.appearance.light_palette[field.key])}
318 <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
319 {/if}
320 </div>
321 {/each}
322 </div>
323 </div>
324
325 <div class="space-y-3">
326 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
327 Theme: Dark
328 </div>
329 <div class="grid gap-4 md:grid-cols-2">
330 {#each PALETTE_FIELDS as field (field.key)}
331 <div class="space-y-2">
332 <Label for={`dark_${field.key}`}>{field.label}</Label>
333 <div class="flex items-center gap-3">
334 <ColorPicker
335 id={`dark_${field.key}`}
336 value={config.appearance.dark_palette[field.key]}
337 onValueChange={(v) => {
338 config!.appearance.dark_palette[field.key] = v;
339 }}
340 />
341 <Input bind:value={config.appearance.dark_palette[field.key]} />
342 </div>
343 {#if !isHexColor(config.appearance.dark_palette[field.key])}
344 <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
345 {/if}
346 </div>
347 {/each}
348 </div>
349 </div>
350 </div>
351 </Card.Content>
352 </Card.Root>
353 </Tabs.Content>
354
355 <Tabs.Content value="search">
356 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
357 <Card.Header>
358 <Card.Title>Search</Card.Title>
359 <Card.Description>Default engine and shortcuts.</Card.Description>
360 </Card.Header>
361 <Card.Content class="space-y-8">
362 <div class="space-y-2">
363 <Label for="search_engine">Default Search Engine (use %s for query)</Label>
364 <Input id="search_engine" bind:value={config.default_search_engine} />
365 </div>
366
367 <div class="space-y-3">
368 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
369 Search Shortcuts
370 </div>
371 <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
372 <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
373 </div>
374 </Card.Content>
375 </Card.Root>
376 </Tabs.Content>
377 </Tabs.Root>
378
379 <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
380 <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
381 {#if saveSuccess}
382 <span class="text-primary">Saved</span>
383 {:else}
384 <span>Ready</span>
385 {/if}
386 {#if themeEvents > 0}
387 <span>Theme x{themeEvents}</span>
388 {/if}
389 </div>
390 <div class="flex items-center gap-3">
391 <AlertDialog.Root bind:open={resetDialogOpen}>
392 <AlertDialog.Trigger disabled={saving}>
393 {#snippet child({ props })}
394 <Button variant="outline" {...props} type="button">
395 Reset Defaults
396 </Button>
397 {/snippet}
398 </AlertDialog.Trigger>
399 <AlertDialog.Content>
400 <AlertDialog.Header>
401 <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402 <AlertDialog.Description>
403 This will reset all settings on this page to their default values.
404 You will still need to click Save to persist the changes.
405 </AlertDialog.Description>
406 </AlertDialog.Header>
407 <AlertDialog.Footer>
408 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409 <AlertDialog.Action onclick={doResetToDefaults}>
410 Reset Defaults
411 </AlertDialog.Action>
412 </AlertDialog.Footer>
413 </AlertDialog.Content>
414 </AlertDialog.Root>
415 <Button onclick={saveConfig} disabled={saving} type="button">
416 {saving ? "Saving…" : "Save"}
417 </Button>
418 </div>
419 </div>
420
421 <!-- Save error display -->
422 {#if saveError}
423 <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
424 {saveError}
425 </div>
426 {/if}
427 {/if}
428 </div>
429</ConfigShell>